From fe214480b5c69ee41083a21e23e384f37b501010 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:11:51 +0000 Subject: [PATCH 01/28] Endpoints: add your own OpenAI-compatible servers from Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine could already run any OpenAI-compatible model registered through `shadowcode models --use` or config.yaml, but the desktop had no way to add one and the picker never listed them. Settings › Accounts gains a "Your own endpoints" card: choose Ollama, LM Studio, a llama.cpp or vLLM server, xAI or any OpenAI-compatible API, find its models, tick the ones you want, then test or remove them. - POST /api/providers/models lists one server's models (Ollama's /api/tags, otherwise /models) with the typed key or, on this computer's window only, the saved one. Nothing is saved; addresses with credentials, a query or a fragment are refused and a bare host gains /v1. - POST /api/models/endpoints registers the chosen models and saves a typed key under a per-service name (api.deepseek.com uses DEEPSEEK_API_KEY), so two services never overwrite each other's key. - POST /api/models/remove removes a model added this way; keys stay. - GET /api/picker lists these models (`custom: true`): loopback servers with the models on this computer, others as API rows that are off in offline mode and ask for their key if a saved key goes missing. Tasks on them run ShadowCode's own agent loop through the registry. docs/NATIVE_ROUTING.md pointed at a "Custom model…" control that does not exist; it now names the card. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fn7NbipAkYG7cYZxHk8xRu --- docs/API_CONTRACT.md | 37 ++ docs/NATIVE_ROUTING.md | 4 +- docs/USER_GUIDE.md | 22 + native/core/src/endpoints.rs | 330 +++++++++++ native/core/src/lib.rs | 1 + native/core/src/models.rs | 229 ++++++-- native/core/src/service/model_catalog.rs | 161 ++++++ native/core/src/store.rs | 4 + native/core/tests/custom_endpoints.rs | 397 ++++++++++++++ ui/e2e/endpoints.spec.ts | 151 ++++++ ui/e2e/fakeBackend.ts | 208 +++++++ ui/src/api.ts | 63 +++ ui/src/components.css | 73 +++ ui/src/components/settings/AccountsPage.tsx | 10 +- ui/src/components/settings/EndpointsCard.tsx | 542 +++++++++++++++++++ ui/src/lib/picker.test.ts | 34 ++ ui/src/lib/picker.ts | 15 +- 17 files changed, 2224 insertions(+), 57 deletions(-) create mode 100644 native/core/src/endpoints.rs create mode 100644 native/core/tests/custom_endpoints.rs create mode 100644 ui/e2e/endpoints.spec.ts create mode 100644 ui/src/components/settings/EndpointsCard.tsx diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 614f8108..45c8ab33 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -466,8 +466,10 @@ need a Unix build. ShadowCode 1.x ships for Linux, where all of them exist. | `POST` | `/api/mcp/servers/delete` | stable | allowed | [Extensions](#extensions) | | `POST` | `/api/memory` | stable | allowed | [Slash commands and memory](#slash-commands-and-memory) | | `GET` | `/api/models` | stable | allowed | [Accounts and models](#accounts-and-models) | +| `POST` | `/api/models/endpoints` | stable | allowed | [Accounts and models](#accounts-and-models) | | `GET` | `/api/models/outcomes` | experimental | allowed | [Accounts and models](#accounts-and-models) | | `POST` | `/api/models/register` | stable | allowed | [Accounts and models](#accounts-and-models) | +| `POST` | `/api/models/remove` | stable | allowed | [Accounts and models](#accounts-and-models) | | `POST` | `/api/models/select` | stable | allowed | [Accounts and models](#accounts-and-models) | | `POST` | `/api/models/test` | stable | allowed | [Accounts and models](#accounts-and-models) | | `GET` | `/api/onboarding` | stable | allowed | [Settings and health](#settings-and-health) | @@ -493,6 +495,7 @@ need a Unix build. ShadowCode 1.x ships for Linux, where all of them exist. | `POST` | `/api/projects/trust` | stable | allowed | [Conversations](#conversations) | | `GET` | `/api/providers` | experimental | allowed | [Accounts and models](#accounts-and-models) | | `GET` | `/api/providers/detect` | experimental | allowed | [Accounts and models](#accounts-and-models) | +| `POST` | `/api/providers/models` | stable | allowed | [Accounts and models](#accounts-and-models) | | `GET` | `/api/remote` | stable | refused | [Remote access](#remote-access) | | `PUT` | `/api/remote` | stable | refused | [Remote access](#remote-access) | | `POST` | `/api/remote/devices/revoke` | stable | refused | [Remote access](#remote-access) | @@ -2309,6 +2312,40 @@ Row = {id: "cli:"|"openrouter"|"local", kind: "subscription"|"api_key"|" `running` from detection. `GET /api/providers/detect?refresh=1` (experimental) → `{providers}`: local servers found (cached 30 s). +### Your own endpoints + +OpenAI-compatible servers the user adds (Ollama, LM Studio, a llama.cpp or +vLLM server, xAI, OpenAI or any compatible API). `provider` is one of +`ollama`, `local` (LM Studio), `llamacpp`, `vllm`, `grok` or +`openai_compatible`. An `endpoint` given as a bare host gains `/v1`; one +with a user name, password, query or fragment is refused. `api_key_env` +defaults to the preset's key name, and for `openai_compatible` on a named +host to `_API_KEY` (`api.deepseek.com` → `DEEPSEEK_API_KEY`; +`api.openai.com` → `OPENAI_API_KEY`). + +- `POST /api/providers/models {provider, endpoint?, api_key?, api_key_env?}` + → `{ok, provider, label, endpoint, api_key_env, key_saved, local, models: + [{id, name, size_bytes, context_limit, capabilities, detail}]}`: the models + the server lists (`/api/tags` for Ollama, otherwise `GET {endpoint}/models`; + 10 s, at most 2 MB). Nothing is saved. The typed `api_key` is sent as a + bearer token; without one the saved key is used, except from a remote + device. Offline, only servers on this computer are reached. +- `POST /api/models/endpoints {provider, endpoint?, models: [name], api_key?, + api_key_env?, context_limit?, vision?}` → `{ok, models: [ModelConfig]}`: + registers each model (id `model:` of provider, endpoint and name) and + saves a typed `api_key` under `api_key_env`. At most 100 models per call. +- `POST /api/models/remove {id}` → `{ok, removed}`: removes a model added for + an endpoint (and older registry rows for the same server and model). The + `model:` default in `config.yaml` and other registry rows are refused. + Saved keys are kept. + +`GET /api/picker` lists these models with `custom: true` and their +`endpoint`. A model on this computer (loopback) is a `local` row named +` · This computer`; any other is an `api` row (`inference: "cloud"`, +billed by the provider, `unavailable` offline). A row whose key was saved +and is now missing is `sign_in`. Tasks on them run ShadowCode's own agent +loop. + ### Local models `GET /api/local-models` → `LocalCatalog`: diff --git a/docs/NATIVE_ROUTING.md b/docs/NATIVE_ROUTING.md index 7424fcfa..cd43ba8c 100644 --- a/docs/NATIVE_ROUTING.md +++ b/docs/NATIVE_ROUTING.md @@ -58,8 +58,8 @@ private secret storage; they are not included in task routing events. Use registry IDs for API configuration. A bare model name is accepted only when it resolves unambiguously, unless it is already an exact saved ID. Add a model -through **Custom model…** or configure it in Settings before assigning it to a -route. The native service also supports `POST /api/models/register` through IPC +through **Settings › Accounts › Your own endpoints** (any OpenAI-compatible +server) or `shadowcode models --use` before assigning it to a route. The native service also supports `POST /api/models/register` through IPC for integrations that need to register a model without changing the default. ## Changes and failures diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b2f26b6e..5efdf80c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -72,6 +72,7 @@ groups: - **API keys**: OpenRouter models, billed per token to your OpenRouter account. Add a key in **Settings › Accounts › OpenRouter**; until then the group offers *Add an OpenRouter API key…*. See [OpenRouter](OPENROUTER.md). + Models from your own cloud endpoints (below) join this group too. Each row shows Local or Cloud, its availability, a *Vision* or *Chat only* badge where it applies, and its usage line. Search filters rows. Vendors with @@ -87,6 +88,27 @@ A row that isn't ready still opens something useful: *Sign in* opens ShadowCode stores the choice for the conversation and uses it as the project's default for new conversations. +### Your own endpoints + +**Settings › Accounts › Your own endpoints** connects any server that speaks +the OpenAI chat API: Ollama, LM Studio, a llama.cpp or vLLM server, xAI, OpenAI, +or another compatible service. Choose **Add an endpoint…**, pick the server +(its usual address is filled in), type an API key if it needs one, and choose +**Find models**. Tick the models you want and choose **Add to the picker**. + +- A server on this computer joins **On this computer**; any other joins + **API keys** and is off in offline mode. Tasks on them run ShadowCode's own + agent, with the same tools, permissions and approvals as local models. +- A typed key is saved on this computer only when you add models, under a + name chosen for the address (`api.deepseek.com` uses `DEEPSEEK_API_KEY`), + so two services never overwrite each other's key. **More options** changes + the name and marks models that can read images. +- If a server doesn't list its models, add one by its exact name. +- **Test** sends one short message and shows how long the answer took. + **Remove** takes a model out of the picker; its saved key stays. +- Cloud endpoints bill you directly. ShadowCode shows a cost only when the + server reports one. + ### How each model did in this project After tasks finish, each row that ran in this project shows a short history: diff --git a/native/core/src/endpoints.rs b/native/core/src/endpoints.rs new file mode 100644 index 00000000..41d381f5 --- /dev/null +++ b/native/core/src/endpoints.rs @@ -0,0 +1,330 @@ +//! The user's own OpenAI-compatible endpoints (Ollama, LM Studio, a llama.cpp +//! or vLLM server, OpenAI, xAI or any compatible API) as composer picker +//! rows. They are registry models the user added (Settings › Accounts, or +//! `shadowcode models --use`); a model a detection probe merely found is not +//! listed until it is added. Tasks on these rows run ShadowCode's own agent +//! loop through `model_registry::resolve`, like any registered model. +use crate::{ + config::{runs_on_this_computer, ModelConfig}, + model_registry, models, +}; +use serde_json::{json, Value}; + +/// Providers that are plain OpenAI-compatible servers (`models::preset`). +pub const PROVIDERS: &[&str] = &[ + "ollama", + "local", + "llamacpp", + "vllm", + "openai_compatible", + "grok", +]; + +/// A registry row the user added for a compatible endpoint: a known provider, +/// an address, and the key name a registration always records. Rows found +/// only by detection have no key name; the managed local runtime has no +/// address. +pub fn is_endpoint_row(row: &Value) -> bool { + PROVIDERS.contains(&row["provider"].as_str().unwrap_or("")) + && !row["endpoint"].as_str().unwrap_or("").is_empty() + && row + .pointer("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/metadata/api_key_env") + .and_then(Value::as_str) + .is_some() +} + +/// "127.0.0.1:11434", "api.openai.com": where the row sends its requests. +pub fn host(endpoint: &str) -> String { + reqwest::Url::parse(endpoint) + .ok() + .and_then(|url| { + let host = url.host_str()?.to_owned(); + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }) + }) + .unwrap_or_else(|| endpoint.to_owned()) +} + +/// The name a new endpoint's key is saved under. Two different services +/// must not overwrite each other's key, so a generic compatible API on a +/// named host gets a name of its own ("api.deepseek.com" → DEEPSEEK_API_KEY; +/// "api.openai.com" keeps the familiar OPENAI_API_KEY). Presets and +/// addresses without a domain name use the preset's name. +pub fn key_name(provider: &str, endpoint: &str) -> String { + let preset = models::preset(provider)["api_key_env"] + .as_str() + .unwrap_or("OPENAI_API_KEY") + .to_owned(); + if provider != "openai_compatible" { + return preset; + } + let Some(host) = reqwest::Url::parse(endpoint) + .ok() + .and_then(|url| url.domain().map(str::to_owned)) + else { + return preset; + }; + let labels: Vec<&str> = host.split('.').filter(|l| !l.is_empty()).collect(); + if labels.len() < 2 { + return preset; + } + // The label before the public suffix, also for "example.co.uk". + let mut index = labels.len() - 2; + if index > 0 && matches!(labels[index], "co" | "com" | "net" | "org" | "ac" | "gov") { + index -= 1; + } + let name: String = labels[index] + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic()) { + return preset; + } + format!("{name}_API_KEY") +} + +/// Picker rows for the user's endpoints. Rows on this computer join the +/// local group; every other row is an API row, billed by its provider and +/// off in offline mode. `has_key` says whether a key name has a saved value. +pub fn picker_rows( + registry: &[Value], + default_id: &str, + offline: bool, + has_key: impl Fn(&str) -> bool, +) -> Vec { + registry + .iter() + .filter(|row| is_endpoint_row(row)) + .filter_map(|row| Some((row, model_registry::from_row(row).ok()?))) + .map(|(row, model)| picker_row(row, &model, default_id, offline, &has_key)) + .collect() +} + +fn picker_row( + row: &Value, + model: &ModelConfig, + default_id: &str, + offline: bool, + has_key: &impl Fn(&str) -> bool, +) -> Value { + let local = runs_on_this_computer(model); + let label = models::preset(&model.provider)["label"] + .as_str() + .unwrap_or("OpenAI-compatible") + .to_owned(); + let host = host(&model.endpoint); + let key_required = row.pointer("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/metadata/key_required") == Some(&json!(true)); + let (availability, availability_label, reason) = if offline && !local { + ( + "unavailable", + "Unavailable", + "Offline mode: only models on this computer run".to_owned(), + ) + } else if key_required && !has_key(&model.api_key_env) { + ( + "sign_in", + "Add API key", + format!("Add the API key for {host} in Settings › Accounts"), + ) + } else { + ("ready", "Ready", String::new()) + }; + let usage = if local { + json!(crate::cli_agent::usage::UsageSnapshot::local()) + } else { + json!({ + "state": "api_key", + "label": "Your endpoint · billed by the provider", + "detail": [ + format!("Tasks go to {host} with your key; the provider bills each request."), + "ShadowCode shows a cost only when the server reports one.", + format!("Context window: {} tokens", model.context_limit), + ], + "plan": null, + "pool": null, + "pool_shared": false, + "windows": [], + "remaining_percent": null, + "credits": null, + "limit_reached": false, + "last_refresh": null, + "provider_usage_url": null, + }) + }; + let capabilities = &row["metadata"]["capabilities"]; + json!({ + "id": model.default, + "provider": model.provider, + "account": "", + "model": model.name, + "route": "native", + "group": if local { crate::cli_agent::picker::GROUP_LOCAL } else { "api" }, + "name": if local { format!("{} · This computer", model.name) } else { model.name.clone() }, + "subtitle": format!("{label} · {host}"), + "inference": if local { "local" } else { "cloud" }, + "availability": availability, + "availability_label": availability_label, + "reason": reason, + "featured": false, + "vision": capabilities["vision"] == true, + "tools": capabilities["tools"] != false, + "is_default": model.default == default_id, + "usage": usage, + "endpoint": model.endpoint, + "custom": true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(provider: &str, endpoint: &str, metadata: Value) -> Value { + let name = "qwen3-coder"; + json!({ + "id": model_registry::model_id(provider, endpoint, name), + "name": name, + "provider": provider, + "endpoint": endpoint, + "context_limit": 32768, + "metadata": metadata, + }) + } + + #[test] + fn only_rows_the_user_added_are_endpoints() { + let added = json!({"api_key_env": "OLLAMA_API_KEY"}); + assert!(is_endpoint_row(&row( + "ollama", + "http://127.0.0.1:11434/v1", + added.clone() + ))); + // Found by detection only: no key name recorded. + assert!(!is_endpoint_row(&row( + "ollama", + "http://127.0.0.1:11434/v1", + json!({"detected": true}) + ))); + // The managed runtime and other row kinds are listed elsewhere. + assert!(!is_endpoint_row(&row("llamacpp", "", added.clone()))); + assert!(!is_endpoint_row(&row( + "openrouter", + "https://openrouter.ai/api/v1", + added.clone() + ))); + assert!(!is_endpoint_row(&row("mock", "", added))); + } + + #[test] + fn rows_on_this_computer_are_local_and_others_are_api_rows() { + let rows = [ + row( + "ollama", + "http://127.0.0.1:11434/v1", + json!({"api_key_env": "OLLAMA_API_KEY"}), + ), + row( + "openai_compatible", + "https://api.deepseek.com/v1", + json!({"api_key_env": "DEEPSEEK_API_KEY", "key_required": true, "capabilities": {"vision": true}}), + ), + ]; + let picked = picker_rows(&rows, "", false, |_| true); + assert_eq!(picked.len(), 2); + assert_eq!(picked[0]["group"], "local"); + assert_eq!(picked[0]["inference"], "local"); + assert_eq!(picked[0]["name"], "qwen3-coder · This computer"); + assert_eq!(picked[0]["subtitle"], "Ollama · 127.0.0.1:11434"); + assert_eq!(picked[0]["usage"]["state"], "local"); + assert_eq!(picked[0]["availability"], "ready"); + assert_eq!(picked[1]["group"], "api"); + assert_eq!(picked[1]["inference"], "cloud"); + assert_eq!( + picked[1]["subtitle"], + "OpenAI-compatible · api.deepseek.com" + ); + assert_eq!(picked[1]["usage"]["state"], "api_key"); + assert_eq!(picked[1]["vision"], true); + assert_eq!(picked[1]["tools"], true); + assert_eq!(picked[1]["custom"], true); + // The row id is the registry id tasks resolve. + assert_eq!(picked[1]["id"], rows[1]["id"]); + } + + #[test] + fn offline_and_missing_keys_block_only_what_they_must() { + let rows = [ + row( + "ollama", + "http://127.0.0.1:11434/v1", + json!({"api_key_env": "OLLAMA_API_KEY"}), + ), + row( + "grok", + "https://api.x.ai/v1", + json!({"api_key_env": "XAI_API_KEY", "key_required": true}), + ), + ]; + let offline = picker_rows(&rows, "", true, |_| true); + assert_eq!(offline[0]["availability"], "ready"); + assert_eq!(offline[1]["availability"], "unavailable"); + let no_key = picker_rows(&rows, "", false, |_| false); + assert_eq!(no_key[0]["availability"], "ready"); + assert_eq!(no_key[1]["availability"], "sign_in"); + assert!(no_key[1]["reason"].as_str().unwrap().contains("api.x.ai")); + } + + #[test] + fn each_named_service_keeps_its_own_key() { + assert_eq!( + key_name("openai_compatible", "https://api.openai.com/v1"), + "OPENAI_API_KEY" + ); + assert_eq!( + key_name("openai_compatible", "https://api.deepseek.com/v1"), + "DEEPSEEK_API_KEY" + ); + assert_eq!( + key_name("openai_compatible", "https://api.groq.com/openai/v1"), + "GROQ_API_KEY" + ); + assert_eq!( + key_name("openai_compatible", "https://llm.example.co.uk/v1"), + "EXAMPLE_API_KEY" + ); + assert_eq!( + key_name("openai_compatible", "https://my-llm.dev/v1"), + "MY_LLM_API_KEY" + ); + // No domain name: the preset's name. + assert_eq!( + key_name("openai_compatible", "http://192.168.1.5:8000/v1"), + "OPENAI_API_KEY" + ); + assert_eq!(key_name("grok", "https://api.x.ai/v1"), "XAI_API_KEY"); + assert_eq!( + key_name("ollama", "http://127.0.0.1:11434/v1"), + "OLLAMA_API_KEY" + ); + for name in [ + key_name("openai_compatible", "https://api.deepseek.com/v1"), + key_name("openai_compatible", "https://my-llm.dev/v1"), + ] { + assert!(crate::config::valid_secret_name(&name), "{name}"); + } + } + + #[test] + fn hosts_name_the_server() { + assert_eq!(host("http://127.0.0.1:11434/v1"), "127.0.0.1:11434"); + assert_eq!(host("https://api.openai.com/v1"), "api.openai.com"); + } +} diff --git a/native/core/src/lib.rs b/native/core/src/lib.rs index 95d10eba..af330c52 100644 --- a/native/core/src/lib.rs +++ b/native/core/src/lib.rs @@ -21,6 +21,7 @@ pub mod context; pub mod control; pub mod data; pub mod effort; +pub mod endpoints; pub mod engine; pub mod events; pub mod flatpak; diff --git a/native/core/src/models.rs b/native/core/src/models.rs index f23a0fca..38b0d4e4 100644 --- a/native/core/src/models.rs +++ b/native/core/src/models.rs @@ -1276,64 +1276,159 @@ pub fn presets() -> Vec { .collect() } -pub async fn detect() -> Vec { - let client = reqwest::Client::builder() - .timeout(Duration::from_millis(1200)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("TLS client"); - futures_util::future::join_all(["ollama","local","llamacpp","vllm"].into_iter().map(|provider|{ - let client=client.clone();async move { - let preset=preset(provider);let base=preset["endpoint"].as_str().unwrap_or(""); - let url=if provider=="ollama"{format!("{}/api/tags",base.trim_end_matches("/v1"))}else{format!("{base}/models")}; - let start=std::time::Instant::now(); - let result: Result = async { - let response = client.get(url).send().await?.error_for_status()?; - let mut stream = response.bytes_stream(); - let mut bytes = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - ensure!(bytes.len() + chunk.len() <= 2_000_000, "Model list exceeded 2 MB"); - bytes.extend_from_slice(&chunk); +/// The base URL a compatible server is called at: trimmed, without a +/// trailing `/`, and with `/v1` added when only a host was given (Ollama, +/// LM Studio, llama.cpp, vLLM, OpenAI and xAI all serve the API there). +/// Addresses with a user name or password, a query or a fragment are refused +/// so a key never ends up in `config.yaml` or the model registry. +pub fn normalize_endpoint(endpoint: &str) -> Result { + let trimmed = endpoint.trim(); + let mut url = reqwest::Url::parse(trimmed).with_context(|| { + format!("Enter a web address such as http://127.0.0.1:11434/v1; found {trimmed:?}") + })?; + ensure!( + matches!(url.scheme(), "http" | "https") && url.host_str().is_some(), + "The address must start with http:// or https://" + ); + ensure!( + url.username().is_empty() && url.password().is_none(), + "Remove the user name or password from the address; enter the API key in its own field" + ); + ensure!( + url.query().is_none() && url.fragment().is_none(), + "Remove the ?query or #fragment from the address" + ); + if matches!(url.path(), "" | "/") { + url.set_path("/v1"); + } + Ok(url.to_string().trim_end_matches('/').to_owned()) +} + +/// Where a server lists its models: Ollama's own `/api/tags` (with sizes and +/// details) beside its `/v1` API, every other server the OpenAI-style +/// `/models`. +fn models_url(provider: &str, base: &str) -> String { + let base = base.trim_end_matches('/'); + if provider == "ollama" { + format!("{}/api/tags", base.trim_end_matches("/v1")) + } else { + format!("{base}/models") + } +} + +/// The models one compatible server offers, from its model list (at most +/// 2 MB). `key` is sent as a bearer token when the server needs one. +pub async fn list_models( + client: &reqwest::Client, + provider: &str, + base: &str, + key: Option<&str>, +) -> Result> { + let mut request = client.get(models_url(provider, base)); + if let Some(key) = key.filter(|key| !key.is_empty()) { + request = request.bearer_auth(key); + } + let response = request.send().await.map_err(|error| { + anyhow::anyhow!( + "Could not reach {base}: {}", + if error.is_timeout() { + "it did not answer in time".to_owned() + } else if error.is_connect() { + "nothing is listening there, or the network refused the connection".to_owned() + } else { + error.without_url().to_string() + } + ) + })?; + let status = response.status(); + if !status.is_success() { + bail!( + "{base} answered {}{}", + status.as_u16(), + http_hint(status.as_u16(), is_loopback_endpoint(base)) + ); + } + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + ensure!( + bytes.len() + chunk.len() <= 2_000_000, + "Model list exceeded 2 MB" + ); + bytes.extend_from_slice(&chunk); + } + let value: Value = serde_json::from_slice(&bytes).context("The model list was not JSON")?; + let empty = Vec::new(); + let rows = value[if provider == "ollama" { + "models" + } else { + "data" + }] + .as_array() + .unwrap_or(&empty); + Ok(rows + .iter() + .filter_map(|m| { + let name = if provider == "ollama" { + m["name"].as_str() + } else { + m["id"].as_str() + }?; + let mut caps = serde_json::Map::new(); + caps.insert("completion".into(), Value::Bool(true)); + if let Some(list) = m.get("capabilities").and_then(Value::as_array) { + for item in list { + if let Some(s) = item.as_str() { + caps.insert(s.to_string(), Value::Bool(true)); + } } - Ok(serde_json::from_slice(&bytes)?) - }.await; + } + Some(json!({ + "id": name, + "name": name, + "size_bytes": m["size"].as_u64().unwrap_or(0), + "context_limit": m.pointer("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/details/context_length").and_then(Value::as_u64).unwrap_or(0), + "capabilities": Value::Object(caps), + "detail": m.pointer("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/details/parameter_size").and_then(Value::as_str).unwrap_or("") + })) + }) + .collect()) +} + +/// A client for model lists: short timeouts, no redirects, and no proxy for +/// servers on this computer. +pub fn list_client(base: &str, timeout: Duration) -> Result { + let mut builder = reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()); + if is_loopback_endpoint(base) { + builder = builder.no_proxy(); + } + Ok(builder.build()?) +} + +pub async fn detect() -> Vec { + futures_util::future::join_all(["ollama", "local", "llamacpp", "vllm"].into_iter().map( + |provider| async move { + let preset = preset(provider); + let base = preset["endpoint"].as_str().unwrap_or(""); + let start = std::time::Instant::now(); + let result = match list_client(base, Duration::from_millis(1200)) { + Ok(client) => list_models(&client, provider, base, None).await, + Err(error) => Err(error), + }; match result { - Ok(value)=>{ - let empty=Vec::new();let rows=value[if provider=="ollama"{"models"}else{"data"}].as_array().unwrap_or(&empty); - let models: Vec<_> = rows - .iter() - .filter_map(|m| { - let name = if provider == "ollama" { - m["name"].as_str() - } else { - m["id"].as_str() - }?; - let mut caps = serde_json::Map::new(); - caps.insert("completion".into(), Value::Bool(true)); - if let Some(list) = m.get("capabilities").and_then(Value::as_array) { - for item in list { - if let Some(s) = item.as_str() { - caps.insert(s.to_string(), Value::Bool(true)); - } - } - } - Some(json!({ - "id": name, - "name": name, - "size_bytes": m["size"].as_u64().unwrap_or(0), - "context_limit": m.pointer("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/details/context_length").and_then(Value::as_u64).unwrap_or(0), - "capabilities": Value::Object(caps), - "detail": m.pointer("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/details/parameter_size").and_then(Value::as_str).unwrap_or("") - })) - }) - .collect(); + Ok(models) => { json!({"provider":provider,"label":preset["label"],"endpoint":base,"running":true,"latency_ms":start.elapsed().as_millis(),"detail":format!("{} models available",models.len()),"models":models}) } - Err(_)=>json!({"provider":provider,"label":preset["label"],"endpoint":base,"running":false,"latency_ms":start.elapsed().as_millis(),"models":[],"detail":"Not reachable"}), + Err(_) => { + json!({"provider":provider,"label":preset["label"],"endpoint":base,"running":false,"latency_ms":start.elapsed().as_millis(),"models":[],"detail":"Not reachable"}) + } } - } - })).await + }, + )) + .await } #[cfg(test)] @@ -1368,6 +1463,34 @@ mod tests { .contains(&key)); } + #[test] + fn endpoints_are_normalized_and_secrets_stay_out_of_them() { + assert_eq!( + normalize_endpoint(" http://127.0.0.1:11434 ").unwrap(), + "http://127.0.0.1:11434/v1" + ); + assert_eq!( + normalize_endpoint("https://api.openai.com/v1/").unwrap(), + "https://api.openai.com/v1" + ); + assert_eq!( + normalize_endpoint("https://api.groq.com/openai/v1").unwrap(), + "https://api.groq.com/openai/v1" + ); + assert!(normalize_endpoint("api.openai.com").is_err()); + assert!(normalize_endpoint("ftp://example.com").is_err()); + assert!(normalize_endpoint("https://me:secret@example.com/v1").is_err()); + assert!(normalize_endpoint("https://example.com/v1?key=secret").is_err()); + assert_eq!( + models_url("ollama", "http://127.0.0.1:11434/v1"), + "http://127.0.0.1:11434/api/tags" + ); + assert_eq!( + models_url("vllm", "http://127.0.0.1:8000/v1/"), + "http://127.0.0.1:8000/v1/models" + ); + } + #[test] fn a_local_server_error_is_named_as_one() { // A local runtime's 500 (a template or tool-call exception) is not diff --git a/native/core/src/service/model_catalog.rs b/native/core/src/service/model_catalog.rs index d31642e2..859f4d97 100644 --- a/native/core/src/service/model_catalog.rs +++ b/native/core/src/service/model_catalog.rs @@ -109,6 +109,7 @@ impl Service { } ("GET", "/api/models/outcomes") => self.model_outcomes(call).await, ("POST", "/api/models/test") => self.test_model(&call.body).await, + ("POST", "/api/providers/models") => self.endpoint_models(&call.body).await, _ => self.blocking(call, Self::model_routes_sync).await, } } @@ -160,6 +161,8 @@ impl Service { let cfg = self.config()?; Ok(json!({"ok":true,"local_engine":local_catalog(&cfg)})) } + ("POST", "/api/models/endpoints") => self.add_endpoint_models(&call.body), + ("POST", "/api/models/remove") => self.remove_endpoint_model(body.id.as_str()), ("POST", "/api/models/register" | "/api/models/select") => { let cfg = self.config()?; let model = if !call.text("provider").is_empty() { @@ -323,6 +326,21 @@ impl Service { key_set, offline, )); + // The user's own compatible endpoints. Each key name is looked up + // once, since a saved key may live in the system keyring. + let registry = model_registry::catalog(&self.engine.store(), &cfg.model)?; + let keys = std::cell::RefCell::new(HashMap::::new()); + targets.extend(crate::endpoints::picker_rows( + ®istry, + &cfg.model.default, + offline, + |name| { + *keys + .borrow_mut() + .entry(name.to_owned()) + .or_insert_with(|| matches!(config::secret(paths, name), Ok(Some(_)))) + }, + )); // Project last: provider observations may change while the local or // OpenRouter catalog is being read. Both vendor views must agree, and // an obsolete per-probe return must not overwrite a newer snapshot. @@ -343,6 +361,141 @@ impl Service { "generated_at": crate::now(), })) } + /// The provider, normalized address and key name of a request for one + /// of the user's compatible endpoints. + fn endpoint_target(&self, body: &Value) -> Result<(String, String, String)> { + let text = |key: &str| body[key].as_str().unwrap_or("").trim(); + let provider = text("provider"); + ensure!( + crate::endpoints::PROVIDERS.contains(&provider), + "Choose a provider: Ollama, LM Studio, llama.cpp, vLLM, xAI or OpenAI-compatible" + ); + let preset = models::preset(provider); + let typed = text("endpoint"); + let endpoint = models::normalize_endpoint(if typed.is_empty() { + preset["endpoint"].as_str().unwrap_or("") + } else { + typed + })?; + let key_env = match text("api_key_env") { + "" => crate::endpoints::key_name(provider, &endpoint), + name => name.to_owned(), + }; + ensure!( + config::valid_secret_name(&key_env), + "The key name must be letters, digits and _, such as OPENAI_API_KEY" + ); + Ok((provider.to_owned(), endpoint, key_env)) + } + /// POST /api/providers/models {provider, endpoint?, api_key?, + /// api_key_env?}: the models one compatible server offers. Nothing is + /// saved. The key typed in the form is used, else (on this computer's + /// window only) the one saved under the key name, so a remote device + /// never sends a saved key to an address it chose. + async fn endpoint_models(&self, body: &Value) -> Result { + let (provider, endpoint, key_env) = self.endpoint_target(body)?; + let local = models::is_loopback_endpoint(&endpoint); + ensure!( + local || !self.config()?.offline(), + "Offline mode: only servers on this computer can be reached" + ); + let saved = config::secret(self.engine.paths(), &key_env)?; + let key = match body["api_key"].as_str().map(str::trim) { + Some(typed) if !typed.is_empty() => Some(typed.to_owned()), + _ if self.remote_device => None, + _ => saved.clone(), + }; + let client = models::list_client(&endpoint, Duration::from_secs(10))?; + let models = models::list_models(&client, &provider, &endpoint, key.as_deref()).await?; + Ok(json!({ + "ok": true, + "provider": provider, + "label": models::preset(&provider)["label"], + "endpoint": endpoint, + "api_key_env": key_env, + "key_saved": saved.is_some(), + "local": local, + "models": models, + })) + } + /// POST /api/models/endpoints {provider, endpoint?, models: [name], + /// api_key?, api_key_env?, context_limit?, vision?}: adds models of one + /// compatible server to the registry, so the picker lists them, and + /// saves a typed key under `api_key_env`. Answers the added models. + fn add_endpoint_models(&self, body: &Value) -> Result { + let (provider, endpoint, key_env) = self.endpoint_target(body)?; + let mut names: Vec = Vec::new(); + for name in body["models"].as_array().into_iter().flatten() { + let name = name.as_str().unwrap_or("").trim(); + if !name.is_empty() && !names.iter().any(|seen| seen == name) { + names.push(name.to_owned()); + } + } + ensure!(!names.is_empty(), "Choose at least one model"); + ensure!(names.len() <= 100, "Add at most 100 models at a time"); + let paths = self.engine.paths(); + let key = body["api_key"].as_str().unwrap_or("").trim(); + if !key.is_empty() { + config::set_secret(paths, &key_env, key)?; + } + // 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. + let key_required = config::secret(paths, &key_env)?.is_some(); + let context_limit = + model_registry::recommended_context(&provider, body["context_limit"].as_u64()); + let mut extra = json!({"key_required": key_required, "added_in": "settings"}); + if let Some(vision) = body["vision"].as_bool() { + extra["capabilities"] = json!({"completion": true, "vision": vision}); + } + let mut added = Vec::new(); + for name in names { + let model = ModelConfig { + default: model_registry::model_id(&provider, &endpoint, &name), + name, + provider: provider.clone(), + endpoint: endpoint.clone(), + api_key_env: key_env.clone(), + keep_alive: "30m".into(), + context_limit, + }; + model_registry::validate(&model)?; + self.check_model_identity(&model)?; + self.register_with(&model, &extra)?; + added.push(model); + } + Ok(json!({"ok": true, "models": added})) + } + /// POST /api/models/remove {id}: removes a model added for one of the + /// user's endpoints, with any older registry row for the same server and + /// model. A saved key stays (another model may use it). + fn remove_endpoint_model(&self, id: &str) -> Result { + let cfg = self.config()?; + ensure!( + id != cfg.model.default, + "This model is the default in config.yaml (model:); change that first" + ); + let store = self.engine.store(); + let rows = store.models()?; + let row = rows + .iter() + .find(|row| row["id"] == id) + .context("That model is not in the registry")?; + ensure!( + crate::endpoints::is_endpoint_row(row), + "Only models added for your own endpoints can be removed here" + ); + let target = model_registry::from_row(row)?; + let mut removed = 0; + for row in &rows { + let same = crate::endpoints::is_endpoint_row(row) + && model_registry::from_row(row) + .is_ok_and(|model| model_registry::same_target(&model, &target)); + if same && row["id"] != json!(cfg.model.default) { + removed += usize::from(store.delete_model(row["id"].as_str().unwrap_or(""))?); + } + } + Ok(json!({"ok": true, "removed": removed})) + } pub(super) fn model_from_body(&self, body: &Value, fallback: &ModelConfig) -> ModelConfig { let provider = body["provider"] .as_str() @@ -418,6 +571,11 @@ impl Service { } } pub(super) fn register(&self, model: &ModelConfig) -> Result<()> { + self.register_with(model, &json!({})) + } + /// `register`, with extra metadata fields (`extra`'s keys replace the + /// stored ones). + fn register_with(&self, model: &ModelConfig, extra: &Value) -> Result<()> { model_registry::validate(model)?; let store = self.engine.store(); let mut metadata = store @@ -433,6 +591,9 @@ impl Service { .unwrap_or(json!({})); metadata["api_key_env"] = json!(model.api_key_env); metadata["keep_alive"] = json!(model.keep_alive); + for (key, value) in extra.as_object().into_iter().flatten() { + metadata[key] = value.clone(); + } store.upsert_model(&json!({"id":model.default,"name":model.name,"provider":model.provider,"endpoint":model.endpoint,"context_limit":model.context_limit,"metadata":metadata})) } pub(super) fn check_model_identity(&self, model: &ModelConfig) -> Result<()> { diff --git a/native/core/src/store.rs b/native/core/src/store.rs index c8409a20..45c444ef 100644 --- a/native/core/src/store.rs +++ b/native/core/src/store.rs @@ -1448,6 +1448,10 @@ impl Store { params![model["id"].as_str().context("Model ID required")?,model["name"].as_str().context("Model name required")?,model["provider"].as_str().context("Provider required")?,model["endpoint"].as_str().unwrap_or(""),model["context_limit"].as_u64().unwrap_or(128000),model.get("metadata").unwrap_or(&json!({})).to_string()])?; Ok(()) } + /// Removes one registry model; true when a row was deleted. + pub fn delete_model(&self, id: &str) -> Result { + Ok(self.execute("DELETE FROM models WHERE id=?", [id])? > 0) + } pub fn upsert_detected_model(&self, model: &Value) -> Result<()> { self.execute("INSERT INTO models(id,name,provider,endpoint,context_limit,metadata) VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET context_limit=CASE WHEN json_extract(models.metadata,'$.api_key_env') IS NULL THEN excluded.context_limit ELSE models.context_limit END,metadata=json_patch(models.metadata,excluded.metadata) WHERE models.name=excluded.name AND models.provider=excluded.provider AND models.endpoint=excluded.endpoint", params![model["id"].as_str().context("Model ID required")?,model["name"].as_str().context("Model name required")?,model["provider"].as_str().context("Provider required")?,model["endpoint"].as_str().context("Endpoint required")?,model["context_limit"].as_u64().context("Context limit required")?,model["metadata"].to_string()])?; diff --git a/native/core/tests/custom_endpoints.rs b/native/core/tests/custom_endpoints.rs new file mode 100644 index 00000000..5cbf8073 --- /dev/null +++ b/native/core/tests/custom_endpoints.rs @@ -0,0 +1,397 @@ +//! The user's own OpenAI-compatible endpoints against a fake server (a small +//! Python server on loopback). It serves `/v1/models` and streaming +//! `/v1/chat/completions` with a canned tool call, checks the bearer key, +//! and records every request. Covers listing an endpoint's models, adding +//! them (the key saved privately), the picker row, a task on the native loop, +//! and removal. +use serde_json::{json, Value}; +use shadowcode_core::{ + config::Config, + paths::AppPaths, + service::{Request, Service}, +}; +use std::{ + fs, + io::{BufRead, BufReader}, + path::Path, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +const FAKE: &str = r#"#!/usr/bin/env python3 +import json, os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +HERE = os.path.dirname(os.path.abspath(__file__)) +KEY = "sk-endpoint-good" + +def log(value): + with open(os.path.join(HERE, "requests.jsonl"), "a") as f: + f.write(json.dumps(value) + "\n") + +def sse(handler, chunks): + handler.send_response(200) + handler.send_header("Content-Type", "text/event-stream") + handler.end_headers() + for chunk in chunks: + handler.wfile.write(("data: " + json.dumps(chunk) + "\n\n").encode()) + handler.wfile.write(b"data: [DONE]\n\n") + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + def log_message(self, *a): + pass + def reply(self, code, body): + data = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + def auth(self): + return self.headers.get("Authorization", "") == "Bearer " + KEY + def do_GET(self): + log({"method": "GET", "path": self.path, "auth": self.auth()}) + if not self.auth(): + return self.reply(401, {"error": {"message": "Incorrect API key provided"}}) + if self.path == "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/v1/models": + return self.reply(200, {"object": "list", "data": [ + {"id": "acme-coder", "object": "model"}, + {"id": "acme-chat", "object": "model"}]}) + self.reply(404, {}) + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0")))) + messages = body.get("messages", []) + log({"method": "POST", "path": self.path, "auth": self.auth(), "model": body.get("model")}) + if not self.auth(): + return self.reply(401, {"error": {"message": "Incorrect API key provided"}}) + if self.path != "/v1/chat/completions": + return self.reply(404, {}) + done = any(m.get("role") == "tool" for m in messages) + if body.get("tools") and not done: + return sse(self, [ + {"choices": [{"delta": {"role": "assistant", "content": None, "tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", "function": { + "name": "write_file", + "arguments": json.dumps({"path": "hello.txt", "content": "hi from my endpoint\n"})}}]}, + "finish_reason": None}]}, + {"choices": [{"delta": {}, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 40, "completion_tokens": 9}}]) + return sse(self, [ + {"choices": [{"delta": {"role": "assistant", "content": "Wrote hello.txt."}, "finish_reason": None}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 55, "completion_tokens": 5}}]) + +server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) +print(server.server_address[1], flush=True) +server.serve_forever() +"#; + +struct Fake { + child: Child, + /// The address as a person would type it: no `/v1`. + host: String, + dir: tempfile::TempDir, +} +impl Drop for Fake { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} +impl Fake { + fn start() -> Self { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("fake_endpoint.py"); + fs::write(&script, FAKE).unwrap(); + let mut child = Command::new("python3") + .arg(&script) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut line = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + let host = format!("http://127.0.0.1:{}", line.trim()); + Self { child, host, dir } + } + fn requests(&self) -> Vec { + fs::read_to_string(self.dir.path().join("requests.jsonl")) + .unwrap_or_default() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } +} + +async fn call(service: &Service, method: &str, path: &str, body: Value) -> anyhow::Result { + service + .dispatch(Request { + method: method.into(), + path: path.into(), + body, + }) + .await +} + +async fn wait_job(service: &Service, id: &str) -> Value { + let started = Instant::now(); + loop { + let job = call(service, "GET", &format!("/api/jobs/{id}"), Value::Null) + .await + .unwrap(); + if matches!( + job["status"].as_str(), + Some("completed" | "failed" | "cancelled") + ) { + return job; + } + assert!(started.elapsed() < Duration::from_secs(30), "{job}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn setup(root: &Path) -> (AppPaths, Service) { + let project = root.join("project"); + fs::create_dir(&project).unwrap(); + let paths = AppPaths::isolated(&root.join("profile")).unwrap(); + Config::patch( + &paths, + json!({ + "trusted_workspaces": [project.clone()], + "permissions": {"mode": "allow_edits"}, + // Never probe the vendor CLIs installed on the test machine. + "cli_agents": {"enabled": false} + }), + ) + .unwrap(); + let service = Service::open(paths.clone(), Some(project)).unwrap(); + (paths, service) +} + +fn endpoint_rows(picker: &Value) -> Vec { + picker["targets"] + .as_array() + .unwrap() + .iter() + .filter(|t| t["custom"] == true) + .cloned() + .collect() +} + +#[tokio::test] +async fn an_endpoint_is_listed_added_used_and_removed() { + // A name no developer machine sets, so the saved key is the one used. + const KEY_NAME: &str = "SHADOWCODE_TEST_ENDPOINT_KEY"; + std::env::remove_var(KEY_NAME); + let fake = Fake::start(); + let root = tempfile::tempdir().unwrap(); + let (paths, service) = setup(root.path()); + let project = root.path().join("project"); + + // The wrong key: the server's refusal, and nothing is saved. + let refused = call( + &service, + "POST", + "/api/providers/models", + json!({"provider":"openai_compatible","endpoint":fake.host,"api_key":"sk-wrong","api_key_env":KEY_NAME}), + ) + .await; + let error = format!("{:#}", refused.unwrap_err()); + assert!( + error.contains("401") && error.contains("check the API key"), + "{error}" + ); + assert!( + !error.contains("sk-wrong"), + "the key is never echoed: {error}" + ); + + // The typed key lists the models; the address gains /v1. + let listed = call( + &service, + "POST", + "/api/providers/models", + json!({"provider":"openai_compatible","endpoint":fake.host,"api_key":"sk-endpoint-good","api_key_env":KEY_NAME}), + ) + .await + .unwrap(); + let endpoint = format!("{}/v1", fake.host); + assert_eq!(listed["endpoint"], endpoint); + assert_eq!(listed["api_key_env"], KEY_NAME); + assert_eq!(listed["key_saved"], false); + assert_eq!(listed["local"], true); + let names: Vec<&str> = listed["models"] + .as_array() + .unwrap() + .iter() + .map(|m| m["id"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["acme-coder", "acme-chat"]); + assert!( + fs::read_to_string(paths.secrets_file()) + .map(|s| !s.contains("sk-endpoint-good")) + .unwrap_or(true), + "listing saves nothing" + ); + assert!(endpoint_rows( + &call(&service, "GET", "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/api/picker", Value::Null) + .await + .unwrap() + ) + .is_empty()); + + // Adding saves the key privately and registers the chosen model. + let added = call( + &service, + "POST", + "/api/models/endpoints", + json!({"provider":"openai_compatible","endpoint":fake.host,"models":["acme-coder"," acme-coder "], + "api_key":"sk-endpoint-good","api_key_env":KEY_NAME}), + ) + .await + .unwrap(); + let models = added["models"].as_array().unwrap(); + assert_eq!(models.len(), 1, "duplicates are added once: {added}"); + assert_eq!(models[0]["endpoint"], endpoint); + assert_eq!(models[0]["api_key_env"], KEY_NAME); + let id = models[0]["default"].as_str().unwrap().to_owned(); + assert!(id.starts_with("model:"), "{id}"); + assert!(!added.to_string().contains("sk-endpoint-good")); + assert!(fs::read_to_string(paths.secrets_file()) + .unwrap() + .contains(KEY_NAME)); + + // Listing again uses the saved key on this computer's window. + let again = call( + &service, + "POST", + "/api/providers/models", + json!({"provider":"openai_compatible","endpoint":fake.host,"api_key_env":KEY_NAME}), + ) + .await + .unwrap(); + assert_eq!(again["key_saved"], true); + assert_eq!(again["models"].as_array().unwrap().len(), 2); + + // The picker lists it with the models on this computer. + let rows = endpoint_rows( + &call(&service, "GET", "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/api/picker", Value::Null) + .await + .unwrap(), + ); + assert_eq!(rows.len(), 1, "{rows:?}"); + let row = &rows[0]; + assert_eq!(row["id"], id.as_str()); + assert_eq!(row["group"], "local"); + assert_eq!(row["inference"], "local"); + assert_eq!(row["availability"], "ready"); + assert_eq!(row["name"], "acme-coder · This computer"); + assert!(row["subtitle"] + .as_str() + .unwrap() + .starts_with("OpenAI-compatible · 127.0.0.1:")); + // A cached (snapshot) read lists it too. + let cached = call(&service, "GET", "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/api/picker?cached=1", Value::Null) + .await + .unwrap(); + assert_eq!(endpoint_rows(&cached).len(), 1); + + // A task runs on ShadowCode's own loop with the saved key. + let job = call( + &service, + "POST", + "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/api/jobs", + json!({"task":"Create hello.txt","model":id}), + ) + .await + .unwrap(); + let done = wait_job(&service, job["id"].as_str().unwrap()).await; + assert_eq!(done["status"], "completed", "{done}"); + assert_eq!( + fs::read_to_string(project.join("hello.txt")).unwrap(), + "hi from my endpoint\n" + ); + let chats: Vec = fake + .requests() + .into_iter() + .filter(|r| r["path"] == "/v1/chat/completions") + .collect(); + assert_eq!(chats.len(), 2, "{chats:?}"); + assert!(chats + .iter() + .all(|r| r["auth"] == true && r["model"] == "acme-coder")); + + // Only the user's endpoint rows can be removed here. + let not_endpoint = call(&service, "POST", "/api/models/remove", json!({"id":"mock"})).await; + assert!(not_endpoint.is_err()); + call(&service, "POST", "/api/models/remove", json!({"id":id})) + .await + .unwrap(); + assert!(endpoint_rows( + &call(&service, "GET", "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/api/picker", Value::Null) + .await + .unwrap() + ) + .is_empty()); + assert!( + call(&service, "POST", "/api/models/remove", json!({"id":id})) + .await + .is_err() + ); + // The saved key stays for other models of this server. + assert!(fs::read_to_string(paths.secrets_file()) + .unwrap() + .contains(KEY_NAME)); +} + +#[tokio::test] +async fn endpoint_requests_are_checked_before_anything_is_sent() { + let root = tempfile::tempdir().unwrap(); + let (paths, service) = setup(root.path()); + let refuse = |body: Value| { + let service = &service; + async move { + format!( + "{:#}", + call(service, "POST", "/api/providers/models", body) + .await + .unwrap_err() + ) + } + }; + assert!(refuse(json!({"provider":"openrouter"})) + .await + .contains("Choose a provider")); + assert!( + refuse(json!({"provider":"vllm","endpoint":"ftp://example.com"})) + .await + .contains("http://") + ); + assert!( + refuse(json!({"provider":"vllm","endpoint":"https://user:secret@example.com/v1"})) + .await + .contains("user name or password") + ); + assert!(refuse( + json!({"provider":"vllm","endpoint":"https://example.com/v1","api_key_env":"not a name"}) + ) + .await + .contains("key name")); + assert!(call( + &service, + "POST", + "/api/models/endpoints", + json!({"provider":"vllm","endpoint":"http://127.0.0.1:9/v1","models":[]}) + ) + .await + .is_err()); + // Offline: only servers on this computer can be reached. + Config::patch(&paths, json!({"network": {"mode": "offline"}})).unwrap(); + assert!(refuse( + json!({"provider":"openai_compatible","endpoint":"https://api.example.com/v1"}) + ) + .await + .contains("Offline mode")); +} diff --git a/ui/e2e/endpoints.spec.ts b/ui/e2e/endpoints.spec.ts new file mode 100644 index 00000000..d65b91a7 --- /dev/null +++ b/ui/e2e/endpoints.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; + +// Settings › Accounts › Your own endpoints: connect a server, add its models +// to the picker, test, use and remove them, against the deterministic fake +// engine (an Ollama server on this computer and a keyed cloud API). +type Call = { method: string; path: string; body: any }; + +async function start(page: Page) { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { stepMs: 60 }); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); +} +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +const fakeLog = (page: Page) => + page.evaluate( + () => + (window as unknown as { __SHADOW_FAKE__: { log: Call[] } }) + .__SHADOW_FAKE__.log, + ); + +async function openCard(page: Page) { + await page.keyboard.press("Control+,"); + const settings = page.getByRole("dialog", { name: "Settings" }); + await settings.getByRole("button", { name: "Accounts", exact: true }).click(); + const card = settings.getByRole("article", { name: "Your own endpoints" }); + await card.scrollIntoViewIfNeeded(); + return { settings, card }; +} + +async function accessible(page: Page) { + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + const results = await new AxeBuilder({ page }) + .include('[role="dialog"]') + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); + } +} + +test("a server on this computer and a keyed cloud API join the picker", async ({ + page, +}) => { + await start(page); + const { settings, card } = await openCard(page); + await expect(card).toContainText("No endpoints added yet."); + + // Ollama on this computer: the address is filled in and no key is needed. + await card.getByRole("button", { name: "Add an endpoint…" }).click(); + const server = card.getByRole("combobox", { name: "Server" }); + await expect(server).toBeFocused(); + await expect(card.getByRole("textbox", { name: "Address" })).toHaveValue( + "http://127.0.0.1:11434/v1", + ); + await card.getByRole("button", { name: "Find models" }).click(); + const models = card.getByRole("group", { name: "Ollama lists 2 models" }); + await models.getByRole("checkbox", { name: "qwen3-coder:30b" }).check(); + await accessible(page); + await models.getByRole("button", { name: "Add 1 to the picker" }).click(); + await expect( + page.getByText("Added 1 model to the model picker."), + ).toBeVisible(); + const added = card.getByRole("list", { name: "Added models" }); + await expect(added).toContainText("qwen3-coder:30b"); + await expect(added).toContainText("Ollama · 127.0.0.1:11434"); + + // Test answers in place. + await added.getByRole("button", { name: "Test qwen3-coder:30b" }).click(); + await expect(added.getByRole("status")).toHaveText("Answered in 0.4 s"); + + // A cloud API: the wrong (missing) key is refused with the server's + // answer, then the typed key lists its models. A bare host gains /v1. + await card.getByRole("button", { name: "Add an endpoint…" }).click(); + await server.selectOption({ label: "OpenAI or another compatible API" }); + const address = card.getByRole("textbox", { name: "Address" }); + await address.fill("https://api.example.com"); + await card.getByRole("button", { name: "Find models" }).click(); + await expect(card.getByRole("alert")).toHaveText( + "https://api.example.com/v1 answered 401; check the API key", + ); + await expect( + card.getByRole("textbox", { name: "Or add a model by its exact name" }), + ).toBeVisible(); + await card.getByLabel("API key", { exact: true }).fill("sk-example-good"); + await card.getByRole("button", { name: "Find models" }).click(); + await expect(address).toHaveValue("https://api.example.com/v1"); + await card + .getByRole("group", { name: "OpenAI-compatible lists 2 models" }) + .getByRole("checkbox", { name: "example-coder" }) + .check(); + await card.getByRole("button", { name: "Add 1 to the picker" }).click(); + await expect(added).toContainText("example-coder"); + await expect(added).toContainText("OpenAI-compatible · api.example.com"); + const addCall = (await fakeLog(page)).find( + (r) => + r.path === "/api/models/endpoints" && + r.body.models?.includes("example-coder"), + ); + // The key name is the engine's choice for this address; the key itself + // goes out only in this request. + expect(addCall?.body).toMatchObject({ + provider: "openai_compatible", + endpoint: "https://api.example.com/v1", + api_key: "sk-example-good", + api_key_env: "EXAMPLE_API_KEY", + models: ["example-coder"], + }); + await expect(card.getByLabel("API key", { exact: true })).toHaveCount(0); + + // Both rows are in the picker, in their groups. + await settings.getByRole("button", { name: "Close" }).first().click(); + await page.getByRole("button", { name: /Model for this task/ }).click(); + const search = page.getByRole("combobox", { name: "Search models" }); + await search.fill("example-coder"); + await expect( + page.getByRole("option", { name: /example-coder/ }), + ).toContainText("Cloud"); + await search.fill("qwen3-coder"); + await expect( + page.getByRole("option", { name: /qwen3-coder:30b · This computer/ }), + ).toContainText("Local"); + await search.press("Enter"); + await expect( + page.getByRole("button", { name: /Model for this task/ }), + ).toContainText("qwen3-coder:30b"); + + // Removing asks first and keeps the saved key. + const again = await openCard(page); + await again.card + .getByRole("button", { name: "Remove example-coder" }) + .click(); + const confirm = page.getByRole("dialog", { name: "Remove example-coder" }); + await expect(confirm).toContainText("A saved API key stays"); + await confirm.getByRole("button", { name: "Remove" }).click(); + await expect( + again.card.getByRole("list", { name: "Added models" }), + ).not.toContainText("example-coder"); +}); diff --git a/ui/e2e/fakeBackend.ts b/ui/e2e/fakeBackend.ts index f29f3877..3e8f8e0f 100644 --- a/ui/e2e/fakeBackend.ts +++ b/ui/e2e/fakeBackend.ts @@ -630,6 +630,10 @@ export function installFakeBackend(options: FakeOptions = {}) { : null) as null | Json, fetched_at: (options.openrouterKey ? now() - 600 : null) as null | number, }, + /** Your own endpoints (POST /api/models/endpoints): registered models + * and the keys saved for them (never served back). */ + endpoints: [] as Json[], + endpointKeys: {} as Record, /** Paid-model spending today (GET /api/spending). */ spentToday: 0.42, jobs: [] as Json[], @@ -1033,6 +1037,113 @@ export function installFakeBackend(options: FakeOptions = {}) { }, }; } + /** Two servers the endpoint form can reach: Ollama on this computer + * (no key) and a cloud API that wants `sk-example-good`. */ + const endpointServers: Record = { + "http://127.0.0.1:11434/v1": { + key: "", + models: ["qwen3-coder:30b", "gemma4:12b"], + }, + "https://api.example.com/v1": { + key: "sk-example-good", + models: ["example-coder", "example-mini"], + }, + }; + const endpointLabels: Record = { + ollama: "Ollama", + local: "LM Studio / local", + llamacpp: "llama.cpp", + vllm: "vLLM", + grok: "Grok / xAI", + openai_compatible: "OpenAI-compatible", + }; + function endpointAddress(raw: string) { + let url: URL; + try { + url = new URL(String(raw || "").trim()); + } catch { + throw new Error( + JSON.stringify({ + error: `Enter a web address such as http://127.0.0.1:11434/v1; found ${JSON.stringify(raw)}`, + }), + ); + } + if (url.pathname === "/" || url.pathname === "") url.pathname = "/v1"; + return url.toString().replace(/\/$/, ""); + } + function endpointKeyName(provider: string, endpoint: string) { + if (provider === "grok") return "XAI_API_KEY"; + if (provider === "ollama") return "OLLAMA_API_KEY"; + if (provider !== "openai_compatible") return "OPENAI_API_KEY"; + const host = new URL(endpoint).hostname; + const labels = host.split("."); + if (labels.length < 2 || /^[\d.]+$/.test(host)) return "OPENAI_API_KEY"; + return `${labels[labels.length - 2].toUpperCase()}_API_KEY`; + } + function endpointRow(m: Json) { + const url = new URL(m.endpoint); + const local = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname); + const offline = state.config.network?.mode === "offline"; + const missingKey = m.key_required && !state.endpointKeys[m.api_key_env]; + return { + id: m.id, + provider: m.provider, + account: "", + model: m.name, + route: "native", + group: local ? "local" : "api", + name: local ? `${m.name} · This computer` : m.name, + subtitle: `${endpointLabels[m.provider] || "OpenAI-compatible"} · ${url.host}`, + inference: local ? "local" : "cloud", + availability: + offline && !local ? "unavailable" : missingKey ? "sign_in" : "ready", + availability_label: + offline && !local + ? "Unavailable" + : missingKey + ? "Add API key" + : "Ready", + reason: + offline && !local + ? "Offline mode: only models on this computer run" + : missingKey + ? `Add the API key for ${url.host} in Settings › Accounts` + : "", + featured: false, + vision: Boolean(m.vision), + tools: true, + is_default: false, + usage: local + ? { + ...unknownUsage, + state: "local", + label: "Runs on this computer · No subscription quota", + } + : { + ...unknownUsage, + state: "api_key", + label: "Your endpoint · billed by the provider", + }, + endpoint: m.endpoint, + custom: true, + }; + } + function endpointTarget(body: Json) { + const provider = String(body?.provider || ""); + if (!endpointLabels[provider]) + throw new Error( + JSON.stringify({ + error: + "Choose a provider: Ollama, LM Studio, llama.cpp, vLLM, xAI or OpenAI-compatible", + }), + ); + const endpoint = endpointAddress(body?.endpoint); + const keyName = + String(body?.api_key_env || "").trim() || + endpointKeyName(provider, endpoint); + return { provider, endpoint, keyName }; + } + function pickerTargets() { const rows: Json[] = []; for (const [key, v] of Object.entries(state.vendors) as [string, Json][]) { @@ -1041,6 +1152,7 @@ export function installFakeBackend(options: FakeOptions = {}) { else rows.push(vendorRow(key, v)); } for (const m of openrouterModels) rows.push(openrouterRow(m)); + for (const m of state.endpoints) rows.push(endpointRow(m)); for (const m of state.local.models as Json[]) rows.push({ id: m.id, @@ -3071,6 +3183,102 @@ export function installFakeBackend(options: FakeOptions = {}) { state.openrouter.fetched_at = now(); return openrouterStatus(); } + if (path === "/api/providers/models" && method === "POST") { + const { provider, endpoint, keyName } = endpointTarget(body); + const server = endpointServers[endpoint]; + const local = endpoint.startsWith("http://127.0.0.1"); + if (state.config.network?.mode === "offline" && !local) + throw new Error( + JSON.stringify({ + error: "Offline mode: only servers on this computer can be reached", + }), + ); + if (!server) + throw new Error( + JSON.stringify({ + error: `Could not reach ${endpoint}: nothing is listening there, or the network refused the connection`, + }), + ); + const typed = String(body?.api_key || "").trim(); + const key = typed || state.endpointKeys[keyName] || ""; + if (server.key && key !== server.key) + throw new Error( + JSON.stringify({ + error: `${endpoint} answered 401; check the API key`, + }), + ); + return { + ok: true, + provider, + label: endpointLabels[provider], + endpoint, + api_key_env: keyName, + key_saved: Boolean(state.endpointKeys[keyName]), + local, + models: server.models.map((id) => ({ + id, + name: id, + size_bytes: 0, + context_limit: 0, + capabilities: { completion: true }, + detail: "", + })), + }; + } + if (path === "/api/models/endpoints" && method === "POST") { + const { provider, endpoint, keyName } = endpointTarget(body); + const names = [ + ...new Set( + ((body?.models || []) as string[]) + .map((n) => String(n).trim()) + .filter(Boolean), + ), + ]; + 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; + const added = names.map((name) => { + const id = `model:${provider}:${endpoint}:${name}`; + const model = { + id, + provider, + endpoint, + name, + api_key_env: keyName, + key_required: Boolean(state.endpointKeys[keyName]), + vision: body?.vision === true, + }; + state.endpoints = [ + ...state.endpoints.filter((m: Json) => m.id !== id), + model, + ]; + return { + default: id, + name, + provider, + endpoint, + api_key_env: keyName, + }; + }); + return { ok: true, models: added }; + } + if (path === "/api/models/remove" && method === "POST") { + const before = state.endpoints.length; + state.endpoints = state.endpoints.filter((m: Json) => m.id !== body?.id); + if (state.endpoints.length === before) + throw new Error( + JSON.stringify({ error: "That model is not in the registry" }), + ); + return { ok: true, removed: 1 }; + } + if (path === "/api/models/test" && method === "POST") + return { + ok: true, + reply: "I can respond.", + latency_ms: 420, + capabilities: { completion: true }, + }; if (path === "/api/openrouter/refresh" && method === "POST") { state.openrouter.fetched_at = now(); return openrouterStatus(); diff --git a/ui/src/api.ts b/ui/src/api.ts index ae340ec3..d456e7af 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -346,6 +346,50 @@ export type OpenRouterStatus = { activity_url: string; }; +/** The OpenAI-compatible servers a user can add (docs/API_CONTRACT.md, + * "Your own endpoints"). */ +export type EndpointProvider = + "ollama" | "local" | "llamacpp" | "vllm" | "grok" | "openai_compatible"; + +export type EndpointRequest = { + provider: EndpointProvider; + /** A bare host gains `/v1`; empty means the preset's address. */ + endpoint?: string; + /** Typed in the form; sent as a bearer token, saved only when adding. */ + api_key?: string; + /** The name the key is saved under; empty means the engine's default. */ + api_key_env?: string; +}; + +/** POST /api/providers/models: what one server lists. Nothing is saved. */ +export type EndpointModels = { + ok: boolean; + provider: EndpointProvider; + label: string; + /** Normalized address, as it will be saved. */ + endpoint: string; + api_key_env: string; + /** A key is already saved under `api_key_env`. */ + key_saved: boolean; + /** The server runs on this computer. */ + local: boolean; + models: { + id: string; + name: string; + size_bytes: number; + context_limit: number; + detail: string; + }[]; +}; + +/** POST /api/models/test: one short completion (45 s limit). */ +export type ModelTest = { + ok: boolean; + reply?: string; + error?: string | null; + latency_ms: number; +}; + /** GET /api/allowance: how much each way of running a model has left, only * from what each source reports (no estimates). */ export type AllowanceState = @@ -1810,6 +1854,25 @@ export const api = { send("/api/openrouter/key", "POST", { api_key: "" }), refreshOpenrouter: () => send("/api/openrouter/refresh", "POST", {}), + /** Your own endpoints: the models one server lists (nothing is saved). */ + endpointModels: (request: EndpointRequest) => + send("/api/providers/models", "POST", request), + /** Adds models of one server to the picker and saves a typed key. */ + addEndpointModels: ( + request: EndpointRequest & { models: string[]; vision?: boolean }, + ) => + send<{ ok: boolean; models: { default: string; name: string }[] }>( + "/api/models/endpoints", + "POST", + request, + ), + /** Removes a model added for an endpoint; its saved key stays. */ + removeEndpointModel: (id: string) => + send<{ ok: boolean; removed: number }>("/api/models/remove", "POST", { + id, + }), + testModel: (id: string) => + send("/api/models/test", "POST", { id }), localModels: () => get("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/api/local-models"), addLocalModel: (path: string) => send<{ ok: boolean; local_engine?: LocalCatalog }>( diff --git a/ui/src/components.css b/ui/src/components.css index 30d79730..899e83e7 100644 --- a/ui/src/components.css +++ b/ui/src/components.css @@ -2208,3 +2208,76 @@ div.activity-step { grid-template-columns: 1fr; } } +/* Accounts › Your own endpoints. */ +.endpoint-rows { + list-style: none; + margin: 2px 0; + padding: 0; + display: grid; + gap: 6px; +} +.endpoint-rows li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + padding: 6px 0; + border-top: 1px solid var(--border); +} +.endpoint-rows li > p { + flex-basis: 100%; +} +.endpoint-row-name { + display: grid; + min-width: 0; + flex: 1 1 220px; +} +.endpoint-row-name strong { + overflow-wrap: anywhere; +} +.endpoint-row-name small { + color: var(--muted); + overflow-wrap: anywhere; +} +.endpoint-row-actions { + gap: 4px; +} +.endpoint-test-ok { + color: var(--text-2); +} +.endpoint-form, +.endpoint-models { + display: grid; + gap: 8px; +} +.endpoint-form h5 { + margin: 4px 0 0; + font-size: 13px; +} +.endpoint-models { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; + margin: 0; +} +.endpoint-models legend { + color: var(--text-2); + font-size: 12.5px; + padding: 0 4px; +} +.endpoint-models .check { + margin: 0; + overflow-wrap: anywhere; +} +.endpoint-advanced summary { + cursor: pointer; + color: var(--text-2); + font-size: 12.5px; +} +.endpoint-advanced[open] { + display: grid; + gap: 8px; +} +.endpoint-manual .row input { + flex: 1; +} diff --git a/ui/src/components/settings/AccountsPage.tsx b/ui/src/components/settings/AccountsPage.tsx index 80616c17..6cdac44d 100644 --- a/ui/src/components/settings/AccountsPage.tsx +++ b/ui/src/components/settings/AccountsPage.tsx @@ -25,6 +25,7 @@ import { usageLabel, } from "../../lib/picker"; import { SpendingLimits } from "./SpendingLimits"; +import { EndpointsCard, isEndpointProvider } from "./EndpointsCard"; const ORDER = ["codex", "claude", "cursor", "antigravity", "grok"]; @@ -63,7 +64,8 @@ export function sourceHost(source: string): string { /** Settings › Accounts: the official CLI of each subscription, its sign-in * state, models and usage. Sign-in and sign-out run the vendors' own commands. * Antigravity's agent server is downloaded here, only when the user chooses - * Install. API keys (OpenRouter, billed per token) follow in their own card. */ + * Install. API keys (OpenRouter, billed per token) and the user's own + * OpenAI-compatible endpoints follow in their own cards. */ export function AccountsPage({ focusVendor, offline = false, @@ -712,6 +714,12 @@ export function AccountsPage({ onChanged={onChanged} onToast={onToast} /> + {confirm && ( + ENDPOINT_PRESETS.some((preset) => preset.id === key); + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** Models shown at once before the list asks for a filter. */ +const LIST_LIMIT = 12; + +/** Settings › Accounts › Your own endpoints: connect an OpenAI-compatible + * server, choose which of its models the picker lists, test and remove + * them. A typed key is saved on this computer only when models are added. */ +export function EndpointsCard({ + focus, + offline, + onChanged, + onToast, +}: { + /** Opened from a picker row that needs its key: scroll here and focus. */ + focus: boolean; + offline: boolean; + onChanged: () => void; + onToast: (text: string, kind: "ok" | "err" | "info") => void; +}) { + const uid = useId().replace(/:/g, ""); + const card = useRef(null); + const server = useRef(null); + const [rows, setRows] = useState(null); + const [loadError, setLoadError] = useState(""); + const [open, setOpen] = useState(false); + const [provider, setProvider] = useState("ollama"); + const [endpoint, setEndpoint] = useState(ENDPOINT_PRESETS[0].endpoint); + const [key, setKey] = useState(""); + const [keyName, setKeyName] = useState(""); + const [vision, setVision] = useState(false); + const [found, setFound] = useState(null); + const [chosen, setChosen] = useState([]); + const [filter, setFilter] = useState(""); + const [manual, setManual] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState<"" | "find" | "add">(""); + const [tests, setTests] = useState>({}); + const [confirm, setConfirm] = useState(null); + const preset = ENDPOINT_PRESETS.find((p) => p.id === provider)!; + + async function reload() { + try { + const picker = await api.pickerCached(); + setRows(picker.targets.filter((target) => target.custom)); + setLoadError(""); + } catch (e) { + setLoadError(message(e)); + } + } + useEffect(() => { + void reload(); + }, []); + useEffect(() => { + if (!focus || !rows) return; + card.current?.scrollIntoView?.({ block: "nearest" }); + card.current?.querySelector("[data-primary]")?.focus(); + }, [focus, rows]); + + // The "Add an endpoint…" button goes away as the form opens; focus moves + // to the form's first control instead of being lost. + useEffect(() => { + if (open) server.current?.focus(); + }, [open]); + + function choose(next: EndpointProvider) { + const nextPreset = ENDPOINT_PRESETS.find((p) => p.id === next)!; + setProvider(next); + // Keep an address the user typed; replace one that was a preset's. + if (ENDPOINT_PRESETS.some((p) => p.endpoint === endpoint) || !endpoint) + setEndpoint(nextPreset.endpoint); + setFound(null); + setChosen([]); + setError(""); + } + + const request = () => ({ + provider, + endpoint: endpoint.trim(), + api_key: key.trim() || undefined, + api_key_env: keyName.trim() || undefined, + }); + + async function find(event?: FormEvent) { + event?.preventDefault(); + if (busy) return; + setBusy("find"); + setError(""); + setFound(null); + try { + const next = await api.endpointModels(request()); + setFound(next); + setEndpoint(next.endpoint); + const added = new Set((rows || []).map((row) => row.model)); + setChosen( + next.models.length === 1 && !added.has(next.models[0].id) + ? [next.models[0].id] + : [], + ); + } catch (e) { + setError(message(e)); + } finally { + setBusy(""); + } + } + + async function add(models: string[]) { + if (!models.length || busy) return; + setBusy("add"); + setError(""); + try { + const result = await api.addEndpointModels({ + ...request(), + endpoint: found?.endpoint || endpoint.trim(), + api_key_env: keyName.trim() || found?.api_key_env || undefined, + models, + vision: vision || undefined, + }); + const count = result.models.length; + onToast( + `Added ${count} model${count === 1 ? "" : "s"} to the model picker.`, + "ok", + ); + setKey(""); + setFound(null); + setChosen([]); + setManual(""); + setOpen(false); + await reload(); + onChanged(); + } catch (e) { + setError(message(e)); + } finally { + setBusy(""); + } + } + + async function test(row: PickerTarget) { + setTests((all) => ({ ...all, [row.id]: "running" })); + let result: ModelTest; + try { + result = await api.testModel(row.id); + } catch (e) { + result = { ok: false, error: message(e), latency_ms: 0 }; + } + setTests((all) => ({ ...all, [row.id]: result })); + } + + async function remove(row: PickerTarget) { + setConfirm(null); + try { + await api.removeEndpointModel(row.id); + onToast(`Removed ${row.model || row.name} from the model picker.`, "ok"); + await reload(); + onChanged(); + } catch (e) { + onToast(message(e), "err"); + } + } + + const added = new Set((rows || []).map((row) => row.model)); + const listed = (found?.models || []).filter( + (model) => + !filter.trim() || + model.id.toLowerCase().includes(filter.trim().toLowerCase()), + ); + return ( +
+
+

Your own endpoints

+ OpenAI-compatible +
+

+ Use Ollama, LM Studio, a llama.cpp or vLLM server, xAI, OpenAI or any + OpenAI-compatible API. The models you add appear in the model picker and + run with ShadowCode's own agent. +

+ {loadError &&

{loadError}

} + {rows && rows.length > 0 && ( +
    + {rows.map((row) => { + const result = tests[row.id]; + return ( +
  • + + {row.model || row.name} + {row.subtitle} + + + {row.availability_label || row.availability} + + + + + + {row.reason &&

    {row.reason}

    } + {result && result !== "running" && ( +

    + {result.ok + ? `Answered in ${(result.latency_ms / 1000).toFixed(1)} s` + : result.error || "No answer"} +

    + )} +
  • + ); + })} +
+ )} + {rows && rows.length === 0 && !open && ( +

No endpoints added yet.

+ )} + {!open && ( +
+ +
+ )} + {open && ( +
void find(e)} + > +
Add an endpoint
+
+ + +
+
+ + { + setEndpoint(e.target.value); + setFound(null); + }} + /> +
+
+ + setKey(e.target.value)} + /> + + {found?.key_saved && !key + ? `A key is already saved as ${found.api_key_env}; it is used unless you type a new one.` + : "Saved only on this computer, and only when you add models."} + +
+
+ More options +
+ + setKeyName(e.target.value)} + /> + + Letters, digits and _. Two services with different keys need + different names. + +
+ +
+ {offline && !preset.local && ( +

+ Offline mode: only servers on this computer can be reached. +

+ )} + {error && ( +

+ {error} +

+ )} +
+ + +
+ {found && ( +
+ + {found.models.length + ? `${found.label} lists ${found.models.length} model${found.models.length === 1 ? "" : "s"}` + : `${found.label} lists no models`} + + {found.models.length > LIST_LIMIT && ( +
+ + setFilter(e.target.value)} + /> +
+ )} + {listed + .slice(0, filter.trim() ? 200 : LIST_LIMIT) + .map((model) => ( + + ))} + {!filter.trim() && listed.length > LIST_LIMIT && ( +

+ {listed.length - LIST_LIMIT} more · filter to find them +

+ )} +
+ +
+
+ )} + {(error || (found && !found.models.length)) && ( +
+ +
+ setManual(e.target.value)} + /> + +
+
+ )} +
+ )} + {confirm && ( + setConfirm(null)} + > +

Remove {confirm.model || confirm.name}?

+

+ It disappears from the model picker. Conversations that used it need + another model. A saved API key stays on this computer. +

+
+ + +
+
+ )} +
+ ); +} diff --git a/ui/src/lib/picker.test.ts b/ui/src/lib/picker.test.ts index 61dccef4..851f16d1 100644 --- a/ui/src/lib/picker.test.ts +++ b/ui/src/lib/picker.test.ts @@ -206,6 +206,40 @@ describe("grouping", () => { expect(vendorLabel(qwen)).toBe("OpenRouter"); }); + it("puts your own endpoints with local models or API rows, named after their server", () => { + const local = row({ + id: "model:aaa", + provider: "ollama", + group: "local", + inference: "local", + name: "qwen3-coder:30b · This computer", + subtitle: "Ollama · 127.0.0.1:11434", + custom: true, + }); + const cloud = row({ + id: "model:bbb", + provider: "openai_compatible", + group: "api", + name: "deepseek-chat", + subtitle: "OpenAI-compatible · api.deepseek.com", + availability: "sign_in", + availability_label: "Add API key", + custom: true, + }); + const groups = groupTargets([local, cloud]); + expect(groups.local).toEqual([local]); + expect(groups.api).toEqual([cloud]); + expect(groups.subscriptions).toEqual([]); + expect(vendorLabel(cloud)).toBe("OpenAI-compatible"); + expect(vendorKey(local)).toBe("local"); + expect(matchesQuery(cloud, "deepseek")).toBe(true); + // A missing key sends the user to Accounts, where the endpoint card is. + expect(rowAction(cloud)).toEqual({ + kind: "connect", + vendor: "openai_compatible", + }); + }); + it("collapses OpenRouter to one section and searches name, slug and subtitle", () => { const rows = Array.from({ length: 40 }, (_, i) => openrouter(`vendor/model-${i}`, `Vendor: Model ${i}`), diff --git a/ui/src/lib/picker.ts b/ui/src/lib/picker.ts index b9820300..e4d0fe67 100644 --- a/ui/src/lib/picker.ts +++ b/ui/src/lib/picker.ts @@ -73,6 +73,10 @@ export type PickerTarget = { is_default?: boolean; usage?: UsageSnapshot | null; local?: LocalDetail; + /** One of the user's own OpenAI-compatible endpoints (Settings › + * Accounts › Your own endpoints), with the address it calls. */ + custom?: boolean; + endpoint?: string; }; export const UNKNOWN_USAGE = "Usage unavailable · Open provider usage"; @@ -100,7 +104,16 @@ export const isApiKey = (target: PickerTarget) => /** Vendors whose product name is not the first part of the row name * (OpenRouter rows are named after the model: "Qwen: Qwen3 Coder"). */ -const VENDOR_LABELS: Record = { openrouter: "OpenRouter" }; +const VENDOR_LABELS: Record = { + openrouter: "OpenRouter", + // The user's own endpoints (models::preset in the engine). LM Studio's + // provider id is "local", the key every row on this computer shares. + ollama: "Ollama", + llamacpp: "llama.cpp", + vllm: "vLLM", + grok: "xAI", + openai_compatible: "OpenAI-compatible", +}; /** The row name without the " · This computer" suffix, for places that * already carry a Local badge next to it (the composer trigger). */ From 20f210a38d81dbd2ec5c3cd2a4ae001d6979fbc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:15:25 +0000 Subject: [PATCH 02/28] Config: the theme follows the desktop by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window, onboarding and Settings › Appearance all treat a missing theme as "system", but the engine's default (and config.example.yaml) said "light", and a first-run save without a theme stored "light". A profile that never chose a theme therefore opened light on a dark desktop. The default is now "system" everywhere; saved choices are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fn7NbipAkYG7cYZxHk8xRu --- config.example.yaml | 2 +- native/core/src/config.rs | 2 +- native/core/src/service/settings.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 73c1a458..aec7cac5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -167,7 +167,7 @@ guardian: allow_prepare_patch: false ui: - theme: light # system | light | dark + theme: system # system (follow the desktop) | light | dark notify: true # desktop notifications at all notify_approval: true # a task asks for approval notify_failed: true # a task failed diff --git a/native/core/src/config.rs b/native/core/src/config.rs index 53c905a3..939fe50d 100644 --- a/native/core/src/config.rs +++ b/native/core/src/config.rs @@ -337,7 +337,7 @@ impl Default for Config { // Every key the app reads from the untyped groups has its default // here, so `shadowcode config KEY VALUE` can set it and // config.example.yaml documents it (tests/config_keys.rs). - ui: json!({"theme":"light","notify":true,"notify_approval":true,"notify_failed":true,"notify_limit":true,"notify_finished":true,"notify_sound":false}), + ui: json!({"theme":"system","notify":true,"notify_approval":true,"notify_failed":true,"notify_limit":true,"notify_finished":true,"notify_sound":false}), onboarding: json!({"completed":false,"workspace":""}), routing: json!({"enabled":false,"planner":"","coder":"","reviewer":"","tester":"","architecture":"","small_edits":"","vision":"","local":"","suggest_from_history":false}), mcp: json!({"servers":[],"approved":[],"share_with_cli_agents":true}), diff --git a/native/core/src/service/settings.rs b/native/core/src/service/settings.rs index 0a132467..6f4220c6 100644 --- a/native/core/src/service/settings.rs +++ b/native/core/src/service/settings.rs @@ -197,7 +197,7 @@ impl Service { }; cfg.permissions.approve_shell = true; cfg.ui["theme"] = json!(if body.theme.is_empty() { - "light" + "system" } else { body.theme.as_str() }); From 4bfe9414848d570b89bc1f89233810a38dd6b255 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:32:12 +0000 Subject: [PATCH 03/28] Search: find words in the agent's answers and show where they matched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task search (the sidebar and the Tasks tab) matched titles, folders and prompts only, so a conversation was hard to find again by what the agent said, and a result never showed why it matched. GET /api/sessions?q= now also matches each task's answer (its summary). A result whose title doesn't contain the words carries `match`: an excerpt of the newest prompt or answer that matched, on one line, with the match's character offsets. The sidebar and the Tasks tab show it under the title ("Answer · …the TOML reader…") with the words marked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fn7NbipAkYG7cYZxHk8xRu --- docs/API_CONTRACT.md | 7 +- docs/USER_GUIDE.md | 3 + native/core/src/store.rs | 139 ++++++++++++++++++++++- native/core/tests/service_commands.rs | 46 ++++++++ ui/e2e/fakeBackend.ts | 39 ++++++- ui/e2e/search.spec.ts | 53 +++++++++ ui/src/api.ts | 11 ++ ui/src/components/Drawer.tsx | 2 + ui/src/components/SearchSnippet.test.tsx | 34 ++++++ ui/src/components/SearchSnippet.tsx | 18 +++ ui/src/components/Sidebar.tsx | 10 +- ui/src/tasks.css | 28 +++++ 12 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 ui/e2e/search.spec.ts create mode 100644 ui/src/components/SearchSnippet.test.tsx create mode 100644 ui/src/components/SearchSnippet.tsx diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 45c8ab33..d88ea207 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -647,7 +647,12 @@ SessionRow = Session + {compare_id, compare_lane, worktree_task, worktree_source - `GET /api/sessions?q=&workspace=&limit=&include_compare=&include_subagents=` → `{sessions: SessionRow[]}`, newest `updated_at` first. `q` matches the - title, folder or any task prompt. `limit` default 100, max 10 000. + title, folder, any task prompt or any task's answer (`summary`), ignoring + the case of ASCII letters. With `q`, a row whose title does not contain it + carries `match: {field: "prompt"|"answer", snippet, start, end}`: an + excerpt (about 40 characters before and 80 after, on one line, `…` where + cut) of the newest prompt or answer that matched, with the match at + character offsets `start`–`end`. `limit` default 100, max 10 000. Compare lanes and subagent conversations are hidden unless `include_compare=true|1` / `include_subagents=true|1`. `workspace=` also lists conversations running in that project's worktree tasks. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 5efdf80c..55bfc5a0 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -814,6 +814,9 @@ and `.env` files suggest. Nothing runs until you save it. again, unless you closed it yourself (`Ctrl+B`). - `Alt+↑` / `Alt+↓` open the previous or next conversation in the list; `Ctrl+Tab` goes back to the one you had open before. +- **Search tasks** finds words in conversation titles, in your messages and + in the agent's answers. When the title doesn't show the words, the result + shows the message or answer that matched, with the words highlighted. ## Notifications diff --git a/native/core/src/store.rs b/native/core/src/store.rs index 45c444ef..f918b195 100644 --- a/native/core/src/store.rs +++ b/native/core/src/store.rs @@ -662,11 +662,48 @@ impl Store { FROM sessions s WHERE (? IS NULL OR s.workspace=? OR EXISTS(SELECT 1 FROM session_meta w WHERE w.session_id=s.id AND w.key='worktree_source' AND w.value=?)) AND (s.title LIKE ? ESCAPE '!' OR s.workspace LIKE ? ESCAPE '!' - OR EXISTS(SELECT 1 FROM tasks t WHERE t.session_id=s.id AND t.prompt LIKE ? ESCAPE '!')) + OR EXISTS(SELECT 1 FROM tasks t WHERE t.session_id=s.id + AND (t.prompt LIKE ? ESCAPE '!' OR t.summary LIKE ? ESCAPE '!'))) AND (? OR NOT EXISTS(SELECT 1 FROM session_meta c WHERE c.session_id=s.id AND c.key='compare_id')) AND (? OR NOT EXISTS(SELECT 1 FROM session_meta a WHERE a.session_id=s.id AND a.key='subagent_parent')) AND NOT EXISTS(SELECT 1 FROM session_meta o WHERE o.session_id=s.id AND o.key='second_opinion') - ORDER BY s.updated_at DESC LIMIT ?", params![workspace.map(|p|p.to_string_lossy()),workspace.map(|p|p.to_string_lossy()),workspace.map(|p|p.to_string_lossy()),needle,needle,needle,include_compare,include_subagents,limit.clamp(1,10000)]) + ORDER BY s.updated_at DESC LIMIT ?", params![workspace.map(|p|p.to_string_lossy()),workspace.map(|p|p.to_string_lossy()),workspace.map(|p|p.to_string_lossy()),needle,needle,needle,needle,include_compare,include_subagents,limit.clamp(1,10000)]) + .and_then(|mut rows| { + let search = search.trim(); + if search.is_empty() { + return Ok(rows); + } + // Where a conversation matched, unless its title already shows + // it: the newest prompt or answer containing the words. + for row in &mut rows { + let title = row["title"].as_str().unwrap_or(""); + if find_ignoring_ascii_case(title, search).is_some() { + continue; + } + let sid = row["id"].as_str().unwrap_or("").to_owned(); + let hit = self.query( + "SELECT prompt,summary FROM tasks WHERE session_id=? + AND (prompt LIKE ? ESCAPE '!' OR summary LIKE ? ESCAPE '!') + ORDER BY created_at DESC LIMIT 1", + params![sid, needle, needle], + )?; + if let Some(task) = hit.first() { + let found = ["prompt", "summary"].into_iter().find_map(|field| { + let text = task[field].as_str()?; + Some((field, search_snippet(text, search)?)) + }); + if let Some((field, (snippet, start, end))) = found { + row["match"] = json!({ + "field": if field == "prompt" { "prompt" } else { "answer" }, + "snippet": snippet, + "start": start, + "end": end, + }); + } + } + } + Ok(rows) + }) } /// Move a conversation to another folder (a worktree task's conversation /// returns to its project when the worktree is removed). The vendor CLI @@ -1659,6 +1696,72 @@ fn finish_task_on( Ok(sid) } +/// Byte offset of `needle` in `text`, comparing ASCII letters without case +/// (as SQLite's `LIKE` does). +fn find_ignoring_ascii_case(text: &str, needle: &str) -> Option { + let (hay, pin) = (text.as_bytes(), needle.as_bytes()); + if pin.is_empty() || pin.len() > hay.len() { + return None; + } + (0..=hay.len() - pin.len()).find(|&i| hay[i..i + pin.len()].eq_ignore_ascii_case(pin)) +} + +/// A short excerpt of `text` around the first match of `needle`, on one +/// line, with "…" where it was cut, and the match's position in it +/// (characters, start and end). +fn search_snippet(text: &str, needle: &str) -> Option<(String, usize, usize)> { + const BEFORE: usize = 40; + const AFTER: usize = 80; + let at = find_ignoring_ascii_case(text, needle)?; + let end = at + needle.len(); + let mut from = text[..at] + .char_indices() + .rev() + .nth(BEFORE - 1) + .map_or(0, |(i, _)| i); + // Start at a word when the cut lands inside one. + if from > 0 { + if let Some(space) = text[from..at].find(char::is_whitespace) { + from += space; + } + } + let to = text[end..] + .char_indices() + .nth(AFTER) + .map_or(text.len(), |(i, _)| end + i); + // Runs of spaces and line breaks become one space. + let flat = |part: &str| { + let mut out = String::with_capacity(part.len()); + for c in part.chars() { + if !c.is_whitespace() { + out.push(c); + } else if !out.ends_with(' ') { + out.push(' '); + } + } + out + }; + let lead = flat(&text[from..at]); + let lead = if from == 0 { + lead.trim_start().to_owned() + } else { + format!("…{}", lead.trim_start()) + }; + let hit = flat(&text[at..end]); + let tail = flat(&text[end..to]); + let tail = if to == text.len() { + tail.trim_end().to_owned() + } else { + format!("{}…", tail.trim_end()) + }; + let begin = lead.chars().count(); + Some(( + format!("{lead}{hit}{tail}"), + begin, + begin + hit.chars().count(), + )) +} + #[cfg(test)] mod verification_tests { use super::*; @@ -2165,3 +2268,35 @@ mod verification_tests { assert_eq!(result["final_assessment"], "not_completed"); } } + +#[cfg(test)] +mod search_tests { + #[test] + fn search_snippets_show_the_match_in_context() { + let (snippet, start, end) = super::search_snippet("Fixed the Login bug", "login").unwrap(); + assert_eq!(snippet, "Fixed the Login bug"); + assert_eq!(&snippet[start..end], "Login"); + let long = format!( + "{} the parser now reads\n\n nested tables {}", + "word ".repeat(30), + "tail ".repeat(40) + ); + let (snippet, start, end) = super::search_snippet(&long, "NESTED").unwrap(); + assert!( + snippet.starts_with('…') && snippet.ends_with('…'), + "{snippet}" + ); + assert!( + snippet.contains("parser now reads nested tables"), + "{snippet}" + ); + let chars: Vec = snippet.chars().collect(); + assert_eq!(chars[start..end].iter().collect::(), "nested"); + // Character offsets stay right after multi-byte text. + let (snippet, start, end) = + super::search_snippet("Größe: ändern Sie die Datei", "datei").unwrap(); + let chars: Vec = snippet.chars().collect(); + assert_eq!(chars[start..end].iter().collect::(), "Datei"); + assert!(super::search_snippet("nothing here", "absent").is_none()); + } +} diff --git a/native/core/tests/service_commands.rs b/native/core/tests/service_commands.rs index 95418dbf..a618ae24 100644 --- a/native/core/tests/service_commands.rs +++ b/native/core/tests/service_commands.rs @@ -1353,6 +1353,52 @@ async fn session_listing_and_literal_search_work_after_tasks_are_saved() { .unwrap(); assert_eq!(found["sessions"].as_array().unwrap().len(), 1); assert_eq!(found["sessions"][0]["id"], plain["id"]); + // The prompt that matched is shown, with the match marked. + let hit = &found["sessions"][0]["match"]; + assert_eq!(hit["field"], "prompt"); + assert_eq!(hit["snippet"], "Find the archived needle"); + assert_eq!(hit["start"], 9); + assert_eq!(hit["end"], 24); + + // The agent's answers are searched too. + let store = service.engine.store(); + let task = store + .create_task(special["id"].as_str().unwrap(), "Tidy the parser") + .unwrap(); + store + .finish_task( + &task, + "completed", + "Moved the TOML reader into its own module and added tests.", + &json!({}), + ) + .unwrap(); + let found = call( + &service, + "GET", + "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/api/sessions?q=toml%20reader", + Value::Null, + ) + .await + .unwrap(); + let sessions = found["sessions"].as_array().unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0]["id"], special["id"]); + assert_eq!(sessions[0]["match"]["field"], "answer"); + assert_eq!( + sessions[0]["match"]["snippet"], + "Moved the TOML reader into its own module and added tests." + ); + // A title match needs no excerpt; no search, no excerpts. + let found = call(&service, "GET", "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/api/sessions?q=Literal", Value::Null) + .await + .unwrap(); + assert!(found["sessions"][0].get("match").is_none()); + assert!(all["sessions"] + .as_array() + .unwrap() + .iter() + .all(|s| s.get("match").is_none())); } #[tokio::test] diff --git a/ui/e2e/fakeBackend.ts b/ui/e2e/fakeBackend.ts index 3e8f8e0f..03e08dc1 100644 --- a/ui/e2e/fakeBackend.ts +++ b/ui/e2e/fakeBackend.ts @@ -3375,13 +3375,50 @@ export function installFakeBackend(options: FakeOptions = {}) { return { ok: true, local_engine: state.local }; if (path === "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/api/sessions" && method === "GET") { const all = ["true", "1"].includes(q.get("include_compare") || ""); + const search = (q.get("q") || "").trim().toLowerCase(); + // Like the engine: titles, prompts and answers; an excerpt of the + // newest prompt or answer that matched unless the title shows it. + const found = (s: Json) => { + if (!search) return { ok: true }; + if ( + String(s.title || "") + .toLowerCase() + .includes(search) + ) + return { ok: true }; + for (const job of [...state.jobs].reverse()) { + if (job.session_id !== s.id) continue; + for (const [field, text] of [ + ["prompt", job.task], + ["answer", job.result?.summary ?? job.summary], + ] as [string, string | undefined][]) { + const at = String(text || "") + .toLowerCase() + .indexOf(search); + if (at < 0) continue; + return { + ok: true, + match: { + field, + snippet: String(text), + start: at, + end: at + search.length, + }, + }; + } + } + return { ok: false }; + }; return { sessions: state.sessions .filter((s: Json) => all || !s.compare_id) - .map((s: Json) => ({ + .map((s: Json): { s: Json; hit: Json } => ({ s, hit: found(s) })) + .filter(({ hit }: { hit: Json }) => hit.ok) + .map(({ s, hit }: { s: Json; hit: Json }) => ({ ...s, compare_id: s.compare_id || null, compare_lane: s.compare_lane || null, + ...(hit.match ? { match: hit.match } : {}), })), }; } diff --git a/ui/e2e/search.spec.ts b/ui/e2e/search.spec.ts new file mode 100644 index 00000000..96ffbd45 --- /dev/null +++ b/ui/e2e/search.spec.ts @@ -0,0 +1,53 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; + +// Task search finds words in prompts and in the agent's answers, and shows +// where it matched, against the deterministic fake engine. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { + stepMs: 60, + completedTask: true, + }); + await page.goto("/"); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +test("search shows the answer or prompt that matched", async ({ page }) => { + const sidebar = page.getByRole("complementary", { + name: "Projects and tasks", + }); + const search = sidebar.getByRole("textbox", { name: "Search tasks" }); + await search.fill("inspection finished"); + const row = sidebar.locator(".task-row").filter({ hasText: "Answer ·" }); + await expect(row).toHaveCount(1); + await expect(row.locator(".search-snippet")).toHaveText( + "Answer · Project inspection finished.", + ); + await expect(row.locator("mark")).toHaveText("inspection finished"); + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + const results = await new AxeBuilder({ page }) + .include(".sidebar") + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); + } + + await search.fill("inspect the"); + await expect(sidebar.locator(".search-snippet")).toHaveText( + "You · Inspect the project", + ); + // Clearing the search lists conversations without excerpts again. + await search.fill(""); + await expect(sidebar.locator(".search-snippet")).toHaveCount(0); + await expect(sidebar.locator(".task-row")).not.toHaveCount(0); +}); diff --git a/ui/src/api.ts b/ui/src/api.ts index d456e7af..bc5c78f9 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -737,6 +737,17 @@ export type Session = { worktree_task?: string | null; /** The project a worktree conversation belongs to (listed under it). */ worktree_source?: string | null; + /** Search results only: the prompt or answer that matched, unless the + * title already shows the words. */ + match?: SessionMatch; +}; +/** An excerpt around a task-search match; `start`/`end` are character + * (code point) offsets of the matched words in `snippet`. */ +export type SessionMatch = { + field: "prompt" | "answer"; + snippet: string; + start: number; + end: number; }; export type SessionDetail = Session & { /** The open worktree task this conversation runs in (as last saved). */ diff --git a/ui/src/components/Drawer.tsx b/ui/src/components/Drawer.tsx index 22a1de71..46b25c35 100644 --- a/ui/src/components/Drawer.tsx +++ b/ui/src/components/Drawer.tsx @@ -3,6 +3,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { api, type Session } from "../api"; import { Empty } from "./cards"; import { ConfirmDialog } from "./ConfirmDialog"; +import { SearchSnippet } from "./SearchSnippet"; import { relativeTime } from "../lib/picker"; import { TerminalPanel } from "./TerminalPanel"; import type { ToolsView } from "./ToolsTab"; @@ -388,6 +389,7 @@ function SessionsTab({ {title} {s.parent_id ? " ↳" : ""} + {s.match && } {s.updated_at ? `Updated ${relativeTime(s.updated_at)}` diff --git a/ui/src/components/SearchSnippet.test.tsx b/ui/src/components/SearchSnippet.test.tsx new file mode 100644 index 00000000..43e5c14f --- /dev/null +++ b/ui/src/components/SearchSnippet.test.tsx @@ -0,0 +1,34 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SearchSnippet } from "./SearchSnippet"; + +describe("SearchSnippet", () => { + it("marks the match by character, not UTF-16 unit", () => { + // "🦀" is one character but two UTF-16 units; the engine counts + // characters. + const { container } = render( + , + ); + expect(container.querySelector("mark")?.textContent).toBe("Größe"); + expect(container.textContent).toBe( + "Answer · …🦀 Größe changed in parser.rs", + ); + }); + + it("names a prompt match as yours", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("You · Fix the bug"); + expect(container.querySelector("mark")?.textContent).toBe("bug"); + }); +}); diff --git a/ui/src/components/SearchSnippet.tsx b/ui/src/components/SearchSnippet.tsx new file mode 100644 index 00000000..0f01aec4 --- /dev/null +++ b/ui/src/components/SearchSnippet.tsx @@ -0,0 +1,18 @@ +import type { SessionMatch } from "../api"; + +/** Where a task search matched a conversation: an excerpt of the prompt or + * the agent's answer with the words marked (GET /api/sessions?q=). */ +export function SearchSnippet({ match }: { match: SessionMatch }) { + // The engine counts characters (code points), as Array.from does. + const chars = Array.from(match.snippet); + return ( + + + {match.field === "answer" ? "Answer" : "You"} ·{" "} + + {chars.slice(0, match.start).join("")} + {chars.slice(match.start, match.end).join("")} + {chars.slice(match.end).join("")} + + ); +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index be060e0e..cce61b56 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -20,6 +20,7 @@ import { api, type Job, type Project, type Session } from "../api"; import { BADGE_LABELS, projectOf, type Badge } from "../lib/badges"; import { readPins, writePins } from "../lib/storage"; import { ConversationMenu, type ConversationAction } from "./ConversationMenu"; +import { SearchSnippet } from "./SearchSnippet"; /** Projects and their recent conversations, with per-conversation badges * (running, needs approval, failed, finished-unread) and a right-click menu @@ -204,7 +205,14 @@ export function Sidebar({ ) : ( )} - {title} + {s.match ? ( + + {title} + + + ) : ( + {title} + )} {badge === "unread" && ( Date: Mon, 12 Oct 2026 14:22:39 +0000 Subject: [PATCH 04/28] Palette: fuzzy search, jump to conversations, recent commands The command palette (Ctrl+K) matched commands by plain substring, could not reach a conversation or project by name, forgot what was used last, and its field had no combobox wiring for screen readers. - lib/fuzzy.ts: a pure matcher. The query's letters must appear in order (case, accents and spaces ignored); a dynamic-programming search picks the best alignment, scoring word starts (including camelCase), runs of consecutive letters and the text's first letter higher, and returns the score and matched positions. "rev ch" finds "Review changes", "cmp" finds "Compare models on this task". Ties keep the given order. - Palette.tsx (moved out of overlays.tsx, still exported from there): with a query, commands, titled conversations (up to 8, project folder beside them) and recent projects (up to 5) are listed in labelled groups, the group holding the best match first so Enter runs what the query fits best. Matches far below the best one (letters scattered across unrelated words) are left out. Matched letters are bold and accent-coloured; options are named by their label alone, with the shortcut or project as their description. - With nothing typed, a Recent group shows the last five commands run from the palette (shadow:palette-recent via readStore/writeStore, tolerating blocked or malformed storage), then the other commands. - ARIA: the field is a combobox with aria-expanded, aria-controls and aria-activedescendant over a grouped listbox of options with aria-selected. Arrows, Page Up/Down and Home/End move (Shift+Home/End still select text), Enter runs, Escape closes as before; the active option scrolls into view, and the pointer moves it without taking focus from the field. - A conversation chosen in the palette opens through the same function as the sidebar's. Every existing palette entry, its order and its behaviour are unchanged. Tests: unit tests for the matcher, component tests for the palette and its conversation/project entries, and e2e/palette.spec.ts (fuzzy search with an axe pass in both themes, opening a conversation, recents across a reload, keyboard and pointer). The fake engine gains a `conversations` option to seed titled conversations. The user guide describes the palette. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fn7NbipAkYG7cYZxHk8xRu --- docs/USER_GUIDE.md | 18 ++ ui/e2e/fakeBackend.ts | 13 + ui/e2e/palette.spec.ts | 226 +++++++++++++++ ui/src/App.tsx | 12 +- ui/src/components/Dialog.focus.test.tsx | 4 +- ui/src/components/Palette.test.tsx | 295 +++++++++++++++++++ ui/src/components/Palette.tsx | 362 ++++++++++++++++++++++++ ui/src/components/overlays.tsx | 71 +---- ui/src/components/shell/AppDialogs.tsx | 18 +- ui/src/components/shell/paletteJumps.ts | 39 +++ ui/src/index.css | 43 ++- ui/src/lib/fuzzy.test.ts | 105 +++++++ ui/src/lib/fuzzy.ts | 167 +++++++++++ 13 files changed, 1294 insertions(+), 79 deletions(-) create mode 100644 ui/e2e/palette.spec.ts create mode 100644 ui/src/components/Palette.test.tsx create mode 100644 ui/src/components/Palette.tsx create mode 100644 ui/src/components/shell/paletteJumps.ts create mode 100644 ui/src/lib/fuzzy.test.ts create mode 100644 ui/src/lib/fuzzy.ts diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 55bfc5a0..549e46e2 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -38,6 +38,24 @@ the same three choices until a model is ready. The sidebar groups conversations by project. **New task** (`Ctrl+N`) starts a new conversation in the current project. +## The command palette + +`Ctrl+K` (or the search button at the top right) opens the command palette. +Type a few letters of what you want: they only need to appear in order, so +*rev ch* finds **Review changes** and *cmp* finds **Compare models on this +task**. Matches at the start of words rank first, and the matched letters are +shown in bold. + +- With something typed, the palette also lists **Conversations** whose title + matches (up to eight, with their project beside them) and **Projects** to + open. Choosing a conversation opens it, just as clicking it in the sidebar + does. Conversations without a title yet are not listed. +- With nothing typed, **Recent** shows the last five commands you ran from + the palette on this computer, then every other command. +- `↑` / `↓` move through the list, `Page Up` / `Page Down` move a page, + `Home` / `End` go to the first or last entry, `Enter` runs the highlighted + one and `Esc` closes the palette. + ## Edit files and inspect context Open a file from the project Files list to edit it with code highlighting, line diff --git a/ui/e2e/fakeBackend.ts b/ui/e2e/fakeBackend.ts index 03e08dc1..1e23a62e 100644 --- a/ui/e2e/fakeBackend.ts +++ b/ui/e2e/fakeBackend.ts @@ -49,6 +49,9 @@ export type FakeOptions = { spendLimitOnApi?: boolean; /** Tasks on a paid API row meet a busy provider twice before answering. */ retryOnApi?: boolean; + /** Earlier conversations in the project, by title, newest first (s2, + * s3, …), all older than the open one. */ + conversations?: string[]; }; export function installFakeBackend(options: FakeOptions = {}) { @@ -761,6 +764,16 @@ export function installFakeBackend(options: FakeOptions = {}) { })), }, }; + (options.conversations || []).forEach((title, i) => + state.sessions.push({ + id: `s${i + 2}`, + workspace, + status: "idle", + title, + updated_at: now() - 60 * (i + 1), + target: null, + }), + ); if (options.firstRun) { state.onboarded = false; state.local.models = []; diff --git a/ui/e2e/palette.spec.ts b/ui/e2e/palette.spec.ts new file mode 100644 index 00000000..dbbed8a5 --- /dev/null +++ b/ui/e2e/palette.spec.ts @@ -0,0 +1,226 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; + +// The command palette (Ctrl+K): fuzzy search over commands, conversations +// and projects, the commands run last first, and the combobox keyboard +// model, against the production build and the fake engine. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { + stepMs: 60, + conversations: [ + "Fix the login redirect", + "Add dark mode to settings", + "Speed up the test suite", + ], + }); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +const palette = (page: Page) => + page.getByRole("dialog", { name: "Command palette" }); +const search = (page: Page) => + palette(page).getByRole("combobox", { name: "Search commands" }); +const options = (page: Page) => palette(page).getByRole("option"); +const active = (page: Page) => + palette(page).locator('[role="option"][aria-selected="true"]'); +const group = (page: Page, name: string) => + palette(page).getByRole("group", { name }); +const panel = (page: Page, name: string) => + page + .getByRole("complementary", { name: "Drawer" }) + .getByRole("group", { name: "Panels" }) + .getByRole("button", { name, exact: true }); + +async function openPalette(page: Page) { + await page.keyboard.press("Control+k"); + await expect(search(page)).toBeFocused(); +} + +/** The field points at the active option, which is the only selected one. */ +async function expectActive(page: Page, name: string) { + await expect(active(page)).toHaveCount(1); + await expect(active(page)).toHaveAccessibleName(name); + const id = await active(page).getAttribute("id"); + await expect(search(page)).toHaveAttribute("aria-activedescendant", id!); + await expect(active(page)).toBeInViewport(); +} + +test("finds commands by letters in order, emphasises the match and runs the best one", async ({ + page, +}) => { + await openPalette(page); + const listbox = palette(page).getByRole("listbox"); + await expect(search(page)).toHaveAttribute("aria-expanded", "true"); + await expect(search(page)).toHaveAttribute( + "aria-controls", + (await listbox.getAttribute("id"))!, + ); + + await page.keyboard.type("cmp"); + await expect(options(page).first()).toHaveAccessibleName( + "Compare models on this task", + ); + await search(page).fill("rev ch"); + await expect(group(page, "Commands")).toBeVisible(); + await expectActive(page, "Review changes"); + await expect(active(page).locator(".palette-match")).toHaveText([ + "Rev", + "ch", + ]); + + // Both themes, with emphasised letters and a conversation listed. + await search(page).fill("set"); + await expect(group(page, "Conversations")).toBeVisible(); + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .include('[role="dialog"]') + .analyze(); + expect(results.violations).toEqual([]); + } + + await search(page).fill("zzqx"); + await expect(palette(page).getByRole("listbox")).toHaveCount(0); + await expect(search(page)).toHaveAttribute("aria-expanded", "false"); + await expect(palette(page).getByRole("status")).toHaveText( + "Nothing matches “zzqx”.", + ); + + await search(page).fill("rev ch"); + await page.keyboard.press("Enter"); + await expect(palette(page)).toHaveCount(0); + await expect(panel(page, "Changes")).toHaveAttribute("aria-pressed", "true"); +}); + +test("opens a conversation by name, as choosing it in the sidebar does", async ({ + page, +}) => { + await openPalette(page); + // Nothing typed: commands only. + await expect(group(page, "Conversations")).toHaveCount(0); + await page.keyboard.type("login red"); + const found = group(page, "Conversations").getByRole("option"); + await expect(found).toHaveCount(1); + await expect(found).toHaveAccessibleName("Fix the login redirect"); + // The project it belongs to describes it. + await expect(found).toHaveAccessibleDescription("demo"); + await expectActive(page, "Fix the login redirect"); + await page.keyboard.press("Enter"); + + await expect(palette(page)).toHaveCount(0); + await expect( + page.getByRole("heading", { level: 1, name: "Fix the login redirect" }), + ).toBeVisible(); + await expect( + page.locator('.task-link[data-session-id="s2"]'), + ).toHaveAttribute("aria-current", "page"); + + // Projects are listed too. + await openPalette(page); + await page.keyboard.type("demo"); + await expect( + group(page, "Projects").getByRole("option"), + ).toHaveAccessibleName("demo"); +}); + +test("the commands run last come first when nothing is typed, after a reload too", async ({ + page, +}) => { + await openPalette(page); + await expect(group(page, "Recent")).toHaveCount(0); + await page.keyboard.type("keyboard short"); + await page.keyboard.press("Enter"); + const help = page.getByRole("dialog", { name: "Help & Shortcuts" }); + await expect(help).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(help).toHaveCount(0); + + await openPalette(page); + await page.keyboard.type("brow fi"); + await page.keyboard.press("Enter"); + await expect(panel(page, "Files")).toHaveAttribute("aria-pressed", "true"); + + await page.reload(); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); + await openPalette(page); + const recent = group(page, "Recent").getByRole("option"); + await expect(recent).toHaveCount(2); + await expect(recent.nth(0)).toHaveAccessibleName("Browse files"); + await expect(recent.nth(1)).toHaveAccessibleName("Keyboard shortcuts"); + // The newest is ready to run again with Enter. + await expectActive(page, "Browse files"); + // Listed once: the full list goes on without them. + await expect( + palette(page).getByRole("option", { name: "Browse files", exact: true }), + ).toHaveCount(1); +}); + +test("arrows, Page Up/Down and Home/End move the active option and keep it in view", async ({ + page, +}) => { + const composer = page.getByRole("textbox", { name: "Message ShadowCode" }); + await composer.focus(); + await openPalette(page); + const names = await options(page).evaluateAll((all) => + all.map((option) => option.getAttribute("aria-label")!), + ); + expect(names[0]).toBe("New task"); + await expectActive(page, names[0]); + await page.keyboard.press("ArrowUp"); + await expectActive(page, names[0]); + await page.keyboard.press("ArrowDown"); + await expectActive(page, names[1]); + await page.keyboard.press("End"); + await expectActive(page, names.at(-1)!); + await page.keyboard.press("ArrowDown"); + await expectActive(page, names.at(-1)!); + await page.keyboard.press("Home"); + await expectActive(page, names[0]); + + // A page is the options that fit, less one. + await page.keyboard.press("PageDown"); + const paged = names.indexOf((await active(page).getAttribute("aria-label"))!); + expect(paged).toBeGreaterThan(3); + await expectActive(page, names[paged]); + await page.keyboard.press("PageDown"); + await expectActive(page, names[Math.min(2 * paged, names.length - 1)]); + await page.keyboard.press("PageUp"); + await expectActive(page, names[paged]); + await page.keyboard.press("PageUp"); + await expectActive(page, names[0]); + + // The pointer moves the active option too, leaving focus in the field. + const terminal = palette(page).getByRole("option", { + name: "Open a terminal", + }); + await terminal.hover(); + await expectActive(page, "Open a terminal"); + await expect(search(page)).toBeFocused(); + + // Escape closes and gives focus back. + await page.keyboard.press("Escape"); + await expect(palette(page)).toHaveCount(0); + await expect(composer).toBeFocused(); + + // A click runs an option. + await openPalette(page); + await palette(page).getByRole("option", { name: "Browse files" }).click(); + await expect(palette(page)).toHaveCount(0); + await expect(panel(page, "Files")).toHaveAttribute("aria-pressed", "true"); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ae6f898c..334c3b5d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -615,6 +615,11 @@ export default function App() { ); const view = compare.view; + /** Choosing a conversation in the sidebar or the command palette. */ + const openConversation = (id: string) => { + compare.setView("chat"); + void nav.openSession(id); + }; const currentSession = sessions.find((s) => s.id === sessionId); const palette = paletteItems({ newTask: () => void nav.newSession(), @@ -658,10 +663,7 @@ export default function App() { onAction={(action, session, title) => void conversations.act(action, session, title) } - onSelect={(id) => { - compare.setView("chat"); - void nav.openSession(id); - }} + onSelect={openConversation} onNew={() => void nav.newSession()} onProject={(path) => void nav.pickProject(path)} onSettings={() => openSettings()} @@ -927,6 +929,8 @@ export default function App() { projects={listedProjects} workspace={workspace} onPickProject={(path) => void nav.pickProject(path)} + sessions={sessions} + onOpenSession={openConversation} /> ); diff --git a/ui/src/components/Dialog.focus.test.tsx b/ui/src/components/Dialog.focus.test.tsx index b8305529..d4b65625 100644 --- a/ui/src/components/Dialog.focus.test.tsx +++ b/ui/src/components/Dialog.focus.test.tsx @@ -69,7 +69,7 @@ it("returns focus to the composer after the real autofocus palette closes with E const composer = screen.getByRole("textbox", { name: "Message ShadowCode" }); composer.focus(); fireEvent.keyDown(composer, { key: "k", ctrlKey: true }); - const search = screen.getByRole("textbox", { name: "Search commands" }); + const search = screen.getByRole("combobox", { name: "Search commands" }); expect(document.activeElement).toBe(search); fireEvent.keyDown(search, { key: "Escape" }); expect(screen.queryByRole("dialog", { name: "Command palette" })).toBeNull(); @@ -88,7 +88,7 @@ it("restores the actual palette opener across repeated StrictMode mounts", () => target.focus(); if (target === opener) fireEvent.click(opener); else fireEvent.keyDown(target, { key: "k", ctrlKey: true }); - const search = screen.getByRole("textbox", { name: "Search commands" }); + const search = screen.getByRole("combobox", { name: "Search commands" }); expect(document.activeElement).toBe(search); fireEvent.keyDown(search, { key: "Escape" }); expect( diff --git a/ui/src/components/Palette.test.tsx b/ui/src/components/Palette.test.tsx new file mode 100644 index 00000000..a1687864 --- /dev/null +++ b/ui/src/components/Palette.test.tsx @@ -0,0 +1,295 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + Palette, + noteRecentCommand, + paletteGroups, + readRecentCommands, + type PaletteItem, +} from "./Palette"; +import { conversationItems, projectItems } from "./shell/paletteJumps"; +import type { Session } from "../api"; + +beforeEach(() => localStorage.clear()); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const LABELS = [ + "New task", + "Choose a model", + "Open project", + "Review changes", + "Commit, push and open a pull request", + "Compare models on this task", + "Comparisons in this project", + "Browse files", + "Open a terminal", + "Settings", + "Keyboard shortcuts", +]; +const command = (label: string, run = vi.fn()): PaletteItem => ({ + id: label.toLowerCase().replace(/\W+/g, "-"), + label, + hint: label === "New task" ? "Ctrl+N" : undefined, + run, +}); +const commands = () => LABELS.map((label) => command(label)); + +const field = () => screen.getByRole("combobox", { name: "Search commands" }); +const type = (text: string) => + fireEvent.change(field(), { target: { value: text } }); +const press = (key: string, init: object = {}) => + fireEvent.keyDown(field(), { key, ...init }); +const optionNames = () => + screen + .getAllByRole("option") + .map((option) => option.textContent?.replace(/Ctrl\+N$/, "")); +const activeOption = () => + document.getElementById(field().getAttribute("aria-activedescendant")!); + +it("finds commands by letters in order and shows the match without changing the name", () => { + render(); + type("rev ch"); + const best = screen.getAllByRole("option")[0]; + expect(best).toHaveAccessibleName("Review changes"); + expect( + Array.from(best.querySelectorAll(".palette-match"), (m) => m.textContent), + ).toEqual(["Rev", "ch"]); + type("cmp"); + expect(optionNames().slice(0, 2)).toEqual([ + "Compare models on this task", + "Comparisons in this project", + ]); + // The shortcut describes the option; it is not part of its name. + type("new"); + const first = screen.getAllByRole("option")[0]; + expect(first).toHaveAccessibleName("New task"); + expect(first).toHaveAccessibleDescription("Ctrl+N"); +}); + +it("wires the field to the listbox: expanded, active option, labelled groups", () => { + render(); + const listbox = screen.getByRole("listbox"); + expect(field()).toHaveAttribute("aria-expanded", "true"); + expect(field()).toHaveAttribute("aria-controls", listbox.id); + expect(screen.getByRole("group", { name: "Commands" })).toBeTruthy(); + const options = screen.getAllByRole("option"); + expect(activeOption()).toBe(options[0]); + expect(options[0]).toHaveAttribute("aria-selected", "true"); + expect(options[1]).toHaveAttribute("aria-selected", "false"); + + type("zzzz"); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(field()).toHaveAttribute("aria-expanded", "false"); + expect(field()).not.toHaveAttribute("aria-activedescendant"); + expect(field()).not.toHaveAttribute("aria-controls"); + expect(screen.getByRole("status")).toHaveTextContent( + "Nothing matches “zzzz”.", + ); +}); + +it("moves with the arrow, Page and Home/End keys, and Enter runs the active command", () => { + const items = commands(); + const onClose = vi.fn(); + render(); + press("ArrowUp"); + expect(activeOption()).toHaveAccessibleName("New task"); + press("ArrowDown"); + press("ArrowDown"); + expect(activeOption()).toHaveAccessibleName("Open project"); + press("End"); + expect(activeOption()).toHaveAccessibleName("Keyboard shortcuts"); + press("ArrowDown"); + expect(activeOption()).toHaveAccessibleName("Keyboard shortcuts"); + press("Home"); + expect(activeOption()).toHaveAccessibleName("New task"); + // Without a laid-out list a page is eight options. + press("PageDown"); + expect(activeOption()).toHaveAccessibleName(LABELS[8]); + press("PageDown"); + expect(activeOption()).toHaveAccessibleName(LABELS.at(-1)); + press("PageUp"); + expect(activeOption()).toHaveAccessibleName(LABELS[2]); + // Shift+Home selects text in the field instead. + press("Home", { shiftKey: true }); + expect(activeOption()).toHaveAccessibleName(LABELS[2]); + // Typing starts again from the best match. + type("files"); + expect(activeOption()).toHaveAccessibleName("Browse files"); + press("Enter"); + expect(onClose).toHaveBeenCalledTimes(1); + expect(items[LABELS.indexOf("Browse files")].run).toHaveBeenCalledTimes(1); +}); + +it("the pointer picks an option without taking focus from the field", () => { + const items = commands(); + render(); + const option = screen.getByRole("option", { name: "Settings" }); + fireEvent.mouseMove(option); + expect(activeOption()).toBe(option); + const down = fireEvent.mouseDown(option); + expect(down).toBe(false); // default prevented: the field keeps focus + fireEvent.click(option); + expect(items[LABELS.indexOf("Settings")].run).toHaveBeenCalledTimes(1); +}); + +it("lists matching conversations and projects once something is typed", () => { + const open = vi.fn(); + const sessions = Array.from({ length: 10 }, (_, i): Session => ({ + id: `s${i}`, + workspace: "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/work/shop", + status: "idle", + title: `Fix the login bug ${i}`, + updated_at: 0, + })); + render( + , + ); + expect(screen.queryByRole("group", { name: "Conversations" })).toBeNull(); + type("login"); + const group = screen.getByRole("group", { name: "Conversations" }); + const found = Array.from(group.querySelectorAll('[role="option"]')); + // At most eight, newest (the list's order) first. + expect(found).toHaveLength(8); + expect(found[0]).toHaveAccessibleName("Fix the login bug 0"); + expect(found[0]).toHaveAccessibleDescription("shop"); + fireEvent.click(found[2]); + expect(open).toHaveBeenCalledWith("s2"); + // Conversations are not commands: nothing is remembered as recent. + expect(readRecentCommands()).toEqual([]); + + type("shop"); + expect( + screen + .getByRole("group", { name: "Projects" }) + .querySelector('[role="option"]'), + ).toHaveAccessibleName("shop"); +}); + +it("shows the commands run last first when nothing is typed", () => { + const items = commands(); + localStorage.setItem( + "shadow:palette-recent", + JSON.stringify(["settings", "gone", "review-changes"]), + ); + render(); + const recent = screen.getByRole("group", { name: "Recent" }); + expect( + Array.from( + recent.querySelectorAll('[role="option"]'), + (o) => o.textContent, + ), + ).toEqual(["Settings", "Review changes"]); + // Listed once: the full list continues without them. + expect(screen.getAllByRole("option", { name: "Settings" })).toHaveLength(1); + expect(activeOption()).toHaveAccessibleName("Settings"); + press("ArrowDown"); + press("ArrowDown"); + expect(activeOption()).toHaveAccessibleName("New task"); + press("Enter"); + expect(readRecentCommands()).toEqual([ + "new-task", + "settings", + "gone", + "review-changes", + ]); +}); + +it("keeps five recent commands and tolerates unreadable storage", () => { + for (const id of ["a", "b", "c", "d", "e", "f", "b"]) noteRecentCommand(id); + expect(readRecentCommands()).toEqual(["b", "f", "e", "d", "c"]); + localStorage.setItem("shadow:palette-recent", "{not json"); + expect(readRecentCommands()).toEqual([]); + localStorage.setItem("shadow:palette-recent", '{"a":1}'); + expect(readRecentCommands()).toEqual([]); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(readRecentCommands()).toEqual([]); + expect(() => noteRecentCommand("a")).not.toThrow(); + render(); + expect(screen.queryByRole("group", { name: "Recent" })).toBeNull(); +}); + +it("puts the group with the best match first", () => { + const conversations = [ + { id: "c1", label: "Optimise the router", run: vi.fn() }, + ]; + const ids = (query: string) => + paletteGroups(query, { commands: commands(), conversations }).map( + (g) => g.id, + ); + expect(ids("settings")).toEqual(["commands"]); + expect(ids("zzz")).toEqual([]); + // "Optimise" fits "opt" better than "Open a terminal" does… + expect(ids("opt")).toEqual(["conversations", "commands"]); + // …and "terminal" fits "ter" better than "router" does. + expect(ids("ter")).toEqual(["commands", "conversations"]); + expect(ids("")).toEqual(["commands"]); +}); + +it("finds conversations by title under their project; untitled ones are left out", () => { + const open = vi.fn(); + const items = conversationItems( + [ + { + id: "a", + workspace: "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/w/app", + status: "idle", + title: "", + updated_at: 0, + }, + { + id: "b", + workspace: "/w/.shadow/worktrees/t1", + worktree_source: "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/w/app", + status: "idle", + title: " Add dark mode ", + updated_at: 0, + }, + ], + open, + ); + expect(items.map(({ label, detail }) => ({ label, detail }))).toEqual([ + { label: "Add dark mode", detail: "app" }, + ]); + items[0].run(); + expect(open).toHaveBeenCalledWith("b"); +}); + +it("leaves out letters scattered across unrelated words when better matches exist", () => { + const labels = (query: string) => + paletteGroups(query, { + commands: [ + command("Workspace health"), + command("Settings"), + command("Stop the agent"), + ], + conversations: [command("Add dark mode to settings")], + }).flatMap((group) => group.entries.map((entry) => entry.item.label)); + expect(labels("set")).toEqual([ + "Settings", + "Stop the agent", + "Add dark mode to settings", + ]); + // On its own, a scattered match is still offered. + expect( + paletteGroups("wsh", { commands: [command("Workspace health")] }).map( + (group) => group.entries.length, + ), + ).toEqual([1]); +}); diff --git a/ui/src/components/Palette.tsx b/ui/src/components/Palette.tsx new file mode 100644 index 00000000..89195170 --- /dev/null +++ b/ui/src/components/Palette.tsx @@ -0,0 +1,362 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import { FolderOpen, MessageSquare } from "lucide-react"; +import { Dialog } from "./Dialog"; +import { fuzzyRank, matchSegments } from "../lib/fuzzy"; +import { readStore, writeStore } from "../lib/storage"; + +export type PaletteItem = { + id: string; + label: string; + /** Secondary text after the label, such as a conversation's project. */ + detail?: string; + hint?: string; + run: () => void; +}; + +type GroupId = "recent" | "commands" | "conversations" | "projects"; +export type PaletteEntry = { + item: PaletteItem; + score: number; + /** Matched characters of the label, shown emphasised. */ + indices: number[]; +}; +export type PaletteGroup = { + id: GroupId; + label: string; + entries: PaletteEntry[]; +}; + +const RECENT_KEY = "shadow:palette-recent"; +const RECENT_LIMIT = 5; +const CONVERSATION_LIMIT = 8; +const PROJECT_LIMIT = 5; +// Matches scoring well below the best one are letters scattered across +// unrelated words ("set" in "Workspace health"): left out, they would bury +// what was meant. Any run of letters at a word start stays above this. +const CLOSE_TO_BEST = 0.6; +const NONE: PaletteItem[] = []; + +/** Commands last run from the palette (ids, newest first). */ +export function readRecentCommands(): string[] { + try { + const value = JSON.parse(readStore(RECENT_KEY) || "[]"); + return Array.isArray(value) + ? value.filter((id) => typeof id === "string").slice(0, RECENT_LIMIT) + : []; + } catch { + return []; + } +} + +export function noteRecentCommand(id: string) { + const recent = [id, ...readRecentCommands().filter((r) => r !== id)]; + writeStore(RECENT_KEY, JSON.stringify(recent.slice(0, RECENT_LIMIT))); +} + +/** What the palette lists. With nothing typed: the commands run last, then + * the others in their usual order. With a query: the commands, + * conversations and projects that match it about as well as the best match + * does, best first, each kind in its own group; the group holding the best + * match comes first, so Enter runs what the query fits best. */ +export function paletteGroups( + query: string, + { + commands, + conversations = NONE, + projects = NONE, + recent = [], + }: { + commands: PaletteItem[]; + conversations?: PaletteItem[]; + projects?: PaletteItem[]; + recent?: string[]; + }, +): PaletteGroup[] { + const plain = (item: PaletteItem): PaletteEntry => ({ + item, + score: 0, + indices: [], + }); + if (!query.trim()) { + const first = recent + .map((id) => commands.find((item) => item.id === id)) + .filter((item): item is PaletteItem => Boolean(item)); + const groups: PaletteGroup[] = [ + { id: "recent", label: "Recent", entries: first.map(plain) }, + { + id: "commands", + label: "Commands", + entries: commands.filter((item) => !first.includes(item)).map(plain), + }, + ]; + return groups.filter((group) => group.entries.length); + } + const rank = (items: PaletteItem[]) => + fuzzyRank(items, query, (item) => item.label); + const matches = { + commands: rank(commands), + conversations: rank(conversations), + projects: rank(projects), + }; + const best = Math.max( + ...Object.values(matches).map((hits) => hits[0]?.score ?? -Infinity), + ); + const cutoff = best - Math.abs(best) * (1 - CLOSE_TO_BEST); + const close = (hits: PaletteEntry[], limit?: number) => + hits.filter((hit) => hit.score >= cutoff).slice(0, limit); + const groups: PaletteGroup[] = [ + { id: "commands", label: "Commands", entries: close(matches.commands) }, + { + id: "conversations", + label: "Conversations", + entries: close(matches.conversations, CONVERSATION_LIMIT), + }, + { + id: "projects", + label: "Projects", + entries: close(matches.projects, PROJECT_LIMIT), + }, + ]; + // Stable: on equal best scores, commands come first. + return groups + .filter((group) => group.entries.length) + .sort((a, b) => b.entries[0].score - a.entries[0].score); +} + +/** The label with its matched characters in emphasised spans. */ +function Highlighted({ text, indices }: { text: string; indices: number[] }) { + if (!indices.length) return <>{text}; + return ( + <> + {matchSegments(text, indices).map((segment, i) => + segment.match ? ( + + {segment.text} + + ) : ( + segment.text + ), + )} + + ); +} + +/** The command palette (Ctrl+K): a combobox over a grouped listbox. Focus + * stays in the search field; the arrow keys, Page Up/Down and Home/End move + * the active option (aria-activedescendant), Enter runs it and Escape + * closes. */ +export function Palette({ + items, + conversations = NONE, + projects = NONE, + onClose, +}: { + /** Commands, in display order. */ + items: PaletteItem[]; + /** Conversations to open, listed once something is typed. */ + conversations?: PaletteItem[]; + /** Projects to open, listed once something is typed. */ + projects?: PaletteItem[]; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const [index, setIndex] = useState(0); + // Read once: running a command closes the palette. + const [recent] = useState(readRecentCommands); + const list = useRef(null); + const uid = useId().replace(/:/g, ""); + const listId = `${uid}-list`; + const optionId = (n: number) => `${uid}-option-${n}`; + const groupId = (id: GroupId) => `${uid}-${id}`; + const groups = useMemo( + () => + paletteGroups(query, { + commands: items, + conversations, + projects, + recent, + }), + [query, items, conversations, projects, recent], + ); + const rows = groups.flatMap((group) => + group.entries.map((entry, i) => ({ entry, group: group.id, first: !i })), + ); + const active = Math.min(index, rows.length - 1); + + useEffect(() => { + const row = rows[active]; + if (!row) return; + // The first option of a group brings its heading into view too. + if (row.first) + document + .getElementById(groupId(row.group)) + ?.scrollIntoView?.({ block: "nearest" }); + document + .getElementById(optionId(active)) + ?.scrollIntoView?.({ block: "nearest" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, query]); + + function choose({ item }: PaletteEntry, group: GroupId) { + // Only commands are remembered; conversations and projects are places. + if (group === "recent" || group === "commands") noteRecentCommand(item.id); + onClose(); + item.run(); + } + + /** Options that fit in the list, less one so the move keeps context. */ + function pageSize() { + const box = list.current; + const option = box?.querySelector('[role="option"]'); + return box && option?.offsetHeight + ? Math.max(1, Math.floor(box.clientHeight / option.offsetHeight) - 1) + : 8; + } + + function onKeyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing) return; + const last = rows.length - 1; + let next: number; + switch (event.key) { + case "ArrowDown": + next = active + 1; + break; + case "ArrowUp": + next = active - 1; + break; + case "PageDown": + next = active + pageSize(); + break; + case "PageUp": + next = active - pageSize(); + break; + // Shift+Home/End still select text in the field. + case "Home": + if (event.shiftKey) return; + next = 0; + break; + case "End": + if (event.shiftKey) return; + next = last; + break; + case "Enter": + if (rows[active]) { + event.preventDefault(); + choose(rows[active].entry, rows[active].group); + } + return; + default: + return; + } + event.preventDefault(); + if (last >= 0) setIndex(Math.max(0, Math.min(last, next))); + } + + let position = 0; + return ( + + 0} + aria-controls={rows.length ? listId : undefined} + aria-activedescendant={rows.length ? optionId(active) : undefined} + placeholder="Type a command…" + onChange={(e) => { + setQuery(e.target.value); + setIndex(0); + }} + onKeyDown={onKeyDown} + /> + {rows.length > 0 && ( +
e.preventDefault()} + > + {groups.map((group) => ( +
+ + {group.entries.map((entry) => { + const n = position++; + const id = optionId(n); + const { item } = entry; + const described = [ + item.detail && `${id}-detail`, + item.hint && `${id}-hint`, + ].filter(Boolean); + return ( +
{ + if (n !== active) setIndex(n); + }} + onClick={() => choose(entry, group.id)} + > + {group.id === "conversations" && ( +
+ ); + })} +
+ ))} +
+ )} +
+ {!rows.length && query.trim() + ? `Nothing matches “${query.trim()}”.` + : ""} +
+
+ ); +} diff --git a/ui/src/components/overlays.tsx b/ui/src/components/overlays.tsx index cd04ddb9..3eb403d7 100644 --- a/ui/src/components/overlays.tsx +++ b/ui/src/components/overlays.tsx @@ -1,5 +1,5 @@ import { Dialog } from "./Dialog"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { Project } from "../api"; import { canPickFiles, pickDirectory } from "../lib/transport"; import { GLOSSARY, GLOSSARY_WORDS } from "../lib/glossary"; @@ -122,73 +122,8 @@ export function Help({ ); } -export type PaletteItem = { - id: string; - label: string; - hint?: string; - run: () => void; -}; - -export function Palette({ - items, - onClose, -}: { - items: PaletteItem[]; - onClose: () => void; -}) { - const [q, setQ] = useState(""); - const [index, setIndex] = useState(0); - const hits = items.filter((c) => - c.label.toLowerCase().includes(q.toLowerCase()), - ); - useEffect(() => setIndex(0), [q]); - return ( - - setQ(e.target.value)} - aria-label="Search commands" - placeholder="Type a command…" - onKeyDown={(ev) => { - if (ev.key === "ArrowDown") { - ev.preventDefault(); - setIndex((i) => Math.min(i + 1, hits.length - 1)); - } - if (ev.key === "ArrowUp") { - ev.preventDefault(); - setIndex((i) => Math.max(i - 1, 0)); - } - if (ev.key === "Enter" && hits[index]) { - ev.preventDefault(); - onClose(); - hits[index].run(); - } - }} - /> -
- {hits.map((item, i) => ( - - ))} - {hits.length === 0 && ( -
No matching commands.
- )} -
-
- ); -} +// The command palette has its own module; it stays importable from here. +export { Palette, type PaletteItem } from "./Palette"; export function ProjectPicker({ projects, diff --git a/ui/src/components/shell/AppDialogs.tsx b/ui/src/components/shell/AppDialogs.tsx index f5686429..4fdc5885 100644 --- a/ui/src/components/shell/AppDialogs.tsx +++ b/ui/src/components/shell/AppDialogs.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, type ComponentProps, type RefObject } from "react"; -import type { LimitsConfig, Project } from "../../api"; +import type { LimitsConfig, Project, Session } from "../../api"; import { AllowancePanel, PlanLimitControl } from "../Allowance"; import type { CompareDialog as CompareDialogComponent } from "../CompareDialog"; import { ConsentDialog } from "../ConsentDialog"; @@ -12,6 +12,7 @@ import { type TrustDialog as TrustDialogComponent, } from "../overlays"; import type { AdvancedTab, SettingsProps, SettingsSection } from "../Settings"; +import { conversationItems, projectItems } from "./paletteJumps"; import { readyLocalTargets } from "../../lib/allowance"; import { billingWarning, type PickerTarget } from "../../lib/picker"; import type { useAllowance } from "../../hooks/useCatalog"; @@ -56,6 +57,8 @@ export function AppDialogs({ projects, workspace, onPickProject, + sessions, + onOpenSession, }: { overlay: Overlay; setOverlay: (overlay: Overlay) => void; @@ -86,6 +89,10 @@ export function AppDialogs({ projects: Project[]; workspace: string; onPickProject: (path: string) => void; + /** Conversations the palette can open, as the sidebar lists them. */ + sessions: Session[]; + /** Opens a conversation as choosing it in the sidebar does. */ + onOpenSession: (id: string) => void; }) { const close = () => setOverlay(""); const openLocal = () => onOpenSettings("local"); @@ -166,7 +173,14 @@ export function AppDialogs({ /> )} {overlay === "help" && } - {overlay === "palette" && } + {overlay === "palette" && ( + + )} {overlay === "project" && ( path.split("/").pop() || path; + +/** Conversations the command palette can open, found by title, with the + * project the sidebar lists them under beside it. Untitled conversations + * have no name to type, so they are left out. */ +export function conversationItems( + sessions: Session[], + open: (id: string) => void, +): PaletteItem[] { + return sessions.flatMap((session) => { + const title = session.title?.trim(); + if (!title) return []; + return [ + { + id: `conversation:${session.id}`, + label: title, + detail: folderName(projectOf(session)), + run: () => open(session.id), + }, + ]; + }); +} + +/** Recent projects the command palette can open, by name. */ +export function projectItems( + projects: Project[], + open: (path: string) => void, +): PaletteItem[] { + return projects.map((project) => ({ + id: `project:${project.path}`, + label: project.name || folderName(project.path), + detail: project.path, + run: () => open(project.path), + })); +} diff --git a/ui/src/index.css b/ui/src/index.css index b34cae04..b024e4f3 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1479,25 +1479,62 @@ button.mini.primary-mini { max-height: 360px; overflow: auto; } +.palette-group-label { + padding: 8px 12px 4px; + color: var(--muted); + font-size: 11px; + font-weight: 600; +} .palette .hit { display: flex; - justify-content: space-between; align-items: center; + gap: 8px; width: 100%; text-align: left; padding: 8px 12px; border-radius: var(--radius-sm); color: var(--text); + cursor: pointer; } -.palette .hit:hover, +.palette .hit > svg { + flex: none; + color: var(--muted); +} +/* The option under the keyboard (and the pointer): the field keeps focus, + so this is the focus indicator. */ .palette .hit.on { - background: var(--hover); + background: var(--accent-soft); +} +.palette-label, +.palette-detail { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.palette-detail { + flex: 1; + color: var(--muted); + font-size: 12px; +} +.palette .hit .kbd { + flex: none; + margin-left: auto; +} +/* Matched letters: bold as well as coloured, so the match does not rely on + colour alone. */ +.palette-match { + color: var(--accent); + font-weight: 650; } .palette-empty { padding: 10px 12px; color: var(--muted); font-size: 12px; } +.palette-empty:empty { + padding: 0; +} .settings { display: grid; diff --git a/ui/src/lib/fuzzy.test.ts b/ui/src/lib/fuzzy.test.ts new file mode 100644 index 00000000..a361259a --- /dev/null +++ b/ui/src/lib/fuzzy.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { fuzzyMatch, fuzzyRank, matchSegments } from "./fuzzy"; + +const labels = (hits: { item: string }[]) => hits.map((hit) => hit.item); + +describe("fuzzyMatch", () => { + it("finds the query's letters in order, across words", () => { + expect(fuzzyMatch("rev ch", "Review changes")?.indices).toEqual([ + 0, 1, 2, 7, 8, + ]); + expect(fuzzyMatch("cmp", "Compare models on this task")?.indices).toEqual([ + 0, 2, 3, + ]); + }); + + it("ignores case, accents and spaces in the query", () => { + expect(fuzzyMatch("REVIEW", "review changes")).not.toBeNull(); + expect(fuzzyMatch("resume", "Résumé the task")).not.toBeNull(); + expect(fuzzyMatch("ÉTÉ", "ete")).not.toBeNull(); + expect(fuzzyMatch("review changes", "Reviewchanges")).not.toBeNull(); + }); + + it("does not match letters out of order or missing", () => { + expect(fuzzyMatch("chrev", "Review changes")).toBeNull(); + expect(fuzzyMatch("xyz", "Review changes")).toBeNull(); + expect(fuzzyMatch("settingsx", "Settings")).toBeNull(); + }); + + it("matches everything, unscored, for an empty query", () => { + expect(fuzzyMatch("", "Anything")).toEqual({ score: 0, indices: [] }); + expect(fuzzyMatch(" ", "Anything")).toEqual({ score: 0, indices: [] }); + }); + + it("prefers a word start, including a case change, over an earlier mid-word letter", () => { + expect(fuzzyMatch("pr", "openProject")?.indices).toEqual([4, 5]); + expect(fuzzyMatch("t", "Get the tests")?.indices).toEqual([4]); + }); + + it("prefers letters that run together over scattered ones", () => { + expect(fuzzyMatch("ana", "banana")?.indices).toEqual([1, 2, 3]); + }); + + it("counts characters as code points", () => { + expect(fuzzyMatch("b", "😀 b")?.indices).toEqual([2]); + }); +}); + +describe("fuzzyRank", () => { + it("ranks word starts, runs and prefixes first", () => { + expect( + labels(fuzzyRank(["Determine", "Open a terminal"], "term", String)), + ).toEqual(["Open a terminal", "Determine"]); + expect( + labels( + fuzzyRank(["Background processes", "Browse files"], "bro", String), + ), + ).toEqual(["Browse files", "Background processes"]); + expect( + labels(fuzzyRank(["Toggle settings", "Settings"], "set", String)), + ).toEqual(["Settings", "Toggle settings"]); + }); + + it("drops items that do not match and keeps the given order on ties", () => { + const items = [ + "Commit, push and open a pull request", + "Compare models on this task", + "Comparisons in this project", + "Browse files", + ]; + expect(labels(fuzzyRank(items, "cmp", String))).toEqual([ + "Compare models on this task", + "Comparisons in this project", + "Commit, push and open a pull request", + ]); + expect(labels(fuzzyRank([items[2], items[1]], "cmp", String))).toEqual([ + items[2], + items[1], + ]); + }); + + it("keeps every item, in order, for an empty query", () => { + expect(labels(fuzzyRank(["b", "a"], "", String))).toEqual(["b", "a"]); + }); +}); + +describe("matchSegments", () => { + it("splits text into matched and unmatched runs without changing it", () => { + const segments = matchSegments("Review changes", [0, 1, 2, 7, 8]); + expect(segments).toEqual([ + { text: "Rev", match: true }, + { text: "iew ", match: false }, + { text: "ch", match: true }, + { text: "anges", match: false }, + ]); + expect(segments.map((s) => s.text).join("")).toBe("Review changes"); + }); + + it("uses the same positions as the matcher", () => { + const text = "😀 b"; + expect(matchSegments(text, fuzzyMatch("b", text)!.indices)).toEqual([ + { text: "😀 ", match: false }, + { text: "b", match: true }, + ]); + }); +}); diff --git a/ui/src/lib/fuzzy.ts b/ui/src/lib/fuzzy.ts new file mode 100644 index 00000000..91ee485c --- /dev/null +++ b/ui/src/lib/fuzzy.ts @@ -0,0 +1,167 @@ +/** Fuzzy matching for the command palette. The query's letters must appear + * in the text in order, though not side by side, ignoring case, accents and + * spaces, so "rev ch" finds "Review changes" and "cmp" finds "Compare models + * on this task". Letters that start words, run together or begin the text + * score higher, so the match a person meant ranks first. */ + +export type FuzzyMatch = { + score: number; + /** Positions of the matched characters (code points, as `Array.from` + * splits the text), ascending. */ + indices: number[]; +}; + +// Points per matched letter, the bonuses that make one alignment better +// than another, and the penalties for letters skipped between matches. +const SCORE_MATCH = 16; +const BONUS_BOUNDARY = 10; +const BONUS_PREFIX = 8; +const BONUS_CONSECUTIVE = 8; +const PENALTY_GAP_START = 3; +const PENALTY_GAP_EXTENSION = 1; +// A light preference for earlier matches: it breaks ties without +// outweighing a word start further along. +const PENALTY_LEADING = 0.5; +const MAX_LEADING_PENALTY = 5; + +const WORD = /[\p{L}\p{N}]/u; +const UPPER = /\p{Lu}/u; +const LOWER = /\p{Ll}/u; +const MARKS = /\p{M}/gu; + +/** One character, lower case and without accents ("É" → "e"). */ +function fold(ch: string) { + if (ch < "\u0080") return ch.toLowerCase(); + return ch.normalize("NFD").replace(MARKS, "").toLowerCase(); +} + +/** A word starts after a space or punctuation, or at a lower→upper case + * change ("openProject"). */ +function startsWord(chars: string[], i: number) { + if (!WORD.test(chars[i])) return false; + if (i === 0 || !WORD.test(chars[i - 1])) return true; + return LOWER.test(chars[i - 1]) && UPPER.test(chars[i]); +} + +/** The best way `query` matches `text`, or null when it does not. An empty + * query matches everything with no score. */ +export function fuzzyMatch(query: string, text: string): FuzzyMatch | null { + const needle = Array.from(query.replace(/\s+/g, "")) + .map(fold) + .filter(Boolean); + const m = needle.length; + if (!m) return { score: 0, indices: [] }; + const chars = Array.from(text); + const n = chars.length; + if (m > n) return null; + const hay = chars.map(fold); + + // Most texts do not match at all: a quick in-order scan rules them out + // before the full search. + for (let i = 0, j = 0; j < m; i++) { + if (i === n) return null; + if (hay[i] === needle[j]) j++; + } + + const bonus = chars.map( + (_, i) => + (i === 0 ? BONUS_PREFIX : 0) + + (startsWord(chars, i) ? BONUS_BOUNDARY : 0), + ); + // score[j*n + i]: the best score for the query's first j+1 letters with + // letter j matched at text position i; from[…]: where letter j-1 was. + const score = new Float64Array(m * n).fill(-Infinity); + const from = new Int32Array(m * n).fill(-1); + for (let j = 0; j < m; j++) { + const row = j * n; + const prev = row - n; + // The best earlier match at least two positions back (a gap), with the + // gap's per-letter penalty folded in so one running maximum serves + // every position. + let gapBest = -Infinity; + let gapAt = -1; + for (let i = j; i < n; i++) { + if (j > 0 && i >= 2) { + const k = i - 2; + const candidate = score[prev + k] + PENALTY_GAP_EXTENSION * k; + // On a tie the later start leaves the shorter gap. + if (candidate >= gapBest && score[prev + k] > -Infinity) { + gapBest = candidate; + gapAt = k; + } + } + if (hay[i] !== needle[j]) continue; + const own = SCORE_MATCH + bonus[i]; + if (j === 0) { + score[i] = own - Math.min(i * PENALTY_LEADING, MAX_LEADING_PENALTY); + continue; + } + let best = -Infinity; + let at = -1; + if (score[prev + i - 1] > -Infinity) { + best = score[prev + i - 1] + BONUS_CONSECUTIVE; + at = i - 1; + } + if (gapAt >= 0) { + const gap = + gapBest - PENALTY_GAP_START - PENALTY_GAP_EXTENSION * (i - 2); + if (gap > best) { + best = gap; + at = gapAt; + } + } + if (at < 0) continue; + score[row + i] = own + best; + from[row + i] = at; + } + } + + const last = (m - 1) * n; + let end = -1; + for (let i = m - 1; i < n; i++) + if ( + score[last + i] > -Infinity && + (end < 0 || score[last + i] > score[last + end]) + ) + end = i; + if (end < 0) return null; + const indices: number[] = new Array(m); + for (let j = m - 1, i = end; j >= 0; j--) { + indices[j] = i; + i = from[j * n + i]; + } + return { score: score[last + end], indices }; +} + +/** The items that match `query`, best first; equal scores keep the items' + * own order. */ +export function fuzzyRank( + items: readonly T[], + query: string, + text: (item: T) => string, +): (FuzzyMatch & { item: T })[] { + const hits: (FuzzyMatch & { item: T })[] = []; + for (const item of items) { + const match = fuzzyMatch(query, text(item)); + if (match) hits.push({ item, ...match }); + } + // Array sort is stable, so ties stay in the given order. + return hits.sort((a, b) => b.score - a.score); +} + +/** `text` split into runs of matched and unmatched characters, for showing + * the matched ones emphasised without changing the text itself. */ +export function matchSegments( + text: string, + indices: readonly number[], +): { text: string; match: boolean }[] { + const marked = new Set(indices); + const segments: { text: string; match: boolean }[] = []; + Array.from(text).forEach((ch, i) => { + const match = marked.has(i); + const last = segments.at(-1); + if (last && last.match === match) last.text += ch; + else segments.push({ text: ch, match }); + }); + return segments; +} From 6a91a7e4f56937ae4bfe497eb17ba5bc68030362 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:34:21 +0000 Subject: [PATCH 05/28] Settings: an Agent page for limits, recovery, subagents, checkpoints and checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent, subagents, checkpoints and verification groups had no UI, and the guides told people to edit config.yaml for them. Settings › Agent now edits every key of those groups: steps, token budget and autonomy profile, tool time limit and output size, parallel reads, history shortening and summaries, provider retries and backoff, fix attempts, the stuck check, the subagent switch and limits, checkpoint switches and sizes, and the list of check commands. Defaults, types and ranges mirror config.rs, subagents.rs, checkpoint.rs and verification.rs. Each field says what it does, its range and default, has a Reset, and is checked as you type (aria-invalid, described by its error); Save waits until every value is valid. A save the engine refuses shows its reason above Save and marks the setting it names, so onSave takes an optional { rethrow } that the other pages do not use. The page sends only the keys that changed. GET /api/config includes a trusted project's own agent values, so sending whole groups would copy a project's choices into the profile. The subagents group is absent until saved; the page then uses the engine's defaults. The autonomy profile caps the token budget, and the page says so when a larger budget would have no effect. The palette gets "Agent · limits, retries, subagents and checks", the polish spec checks the page in both themes, and the user guide, SUBAGENTS.md, SUBSCRIPTIONS.md, VERIFICATION_RECEIPTS.md and config.example.yaml point to the page while keeping the YAML keys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fn7NbipAkYG7cYZxHk8xRu --- config.example.yaml | 4 + docs/SUBAGENTS.md | 6 +- docs/SUBSCRIPTIONS.md | 6 +- docs/USER_GUIDE.md | 70 ++- docs/VERIFICATION_RECEIPTS.md | 3 +- ui/e2e/agentSettings.spec.ts | 174 ++++++ ui/e2e/fakeBackend.ts | 25 + ui/e2e/polish.spec.ts | 1 + ui/src/App.tsx | 3 +- ui/src/components/Settings.tsx | 13 +- ui/src/components/settings/AgentPage.css | 84 +++ ui/src/components/settings/AgentPage.test.tsx | 257 +++++++++ ui/src/components/settings/AgentPage.tsx | 412 +++++++++++++++ ui/src/components/shell/palette.test.ts | 1 + ui/src/components/shell/palette.ts | 5 + ui/src/lib/agentSettings.test.ts | 214 ++++++++ ui/src/lib/agentSettings.ts | 495 ++++++++++++++++++ 17 files changed, 1753 insertions(+), 20 deletions(-) create mode 100644 ui/e2e/agentSettings.spec.ts create mode 100644 ui/src/components/settings/AgentPage.css create mode 100644 ui/src/components/settings/AgentPage.test.tsx create mode 100644 ui/src/components/settings/AgentPage.tsx create mode 100644 ui/src/lib/agentSettings.test.ts create mode 100644 ui/src/lib/agentSettings.ts diff --git a/config.example.yaml b/config.example.yaml index aec7cac5..e3acc3ad 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -60,6 +60,7 @@ sandbox: - .deno # Project checkpoints around shell commands and subscription turns (Rewind). +# Settings › Agent edits these. checkpoints: shell: true vendor: true @@ -82,6 +83,7 @@ spending: logging: level: info +# ShadowCode's own agent (Settings › Agent edits these). agent: max_steps: 64 # model turns per task, 1-1000 tool_timeout_sec: 60 # 1-3600 @@ -157,6 +159,7 @@ hooks: approved: [] # {workspace, path, hash} # Exact checks a task may run to verify its work (shell policy still applies). +# Settings › Agent › Checks edits the list. verification: commands: [] @@ -176,6 +179,7 @@ ui: notify_sound: false # ask the notification server to play its sound # Subagents: children a task starts with `spawn_agent` (native loop only). +# Settings › Agent edits these. subagents: enabled: true max_parallel: 4 # children of one task running at once, 1-8 diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 8e649d6a..d899aabd 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -45,7 +45,8 @@ conversation. It can start one, or several at once with `tasks`. vendor CLI subagent's process. - **Limits.** At most 4 subagents run at the same time, and one task can start 16 in total. Subagents cannot start their own subagents. A subagent stops - after its step limit (the definition's `max_turns`, or 24). + after its step limit (the definition's `max_turns`, or 24). These are the + defaults; change them in **Settings › Agent › Subagents**. - **Models.** A subagent uses, in order: the model the call names, its definition's `model`, its role's model (`explore`, `plan`, `review`, and write agents as `implement`), or the parent task's model. Any picker id @@ -279,7 +280,8 @@ the agent; you can still run it with `/skill `. ## Settings -`config.yaml`: +**Settings › Agent › Subagents** changes the `subagents` group, with each +setting's range and default. By hand, in `config.yaml`: ```yaml subagents: diff --git a/docs/SUBSCRIPTIONS.md b/docs/SUBSCRIPTIONS.md index 591d59b6..b53c72bf 100644 --- a/docs/SUBSCRIPTIONS.md +++ b/docs/SUBSCRIPTIONS.md @@ -206,9 +206,9 @@ never uses it once a turn has started or when images are attached. changed although the agent didn't report editing them (a command it ran, or another program). Changes you make outside ShadowCode's editor during the turn can't be told apart from the agent's and are rewound with them. - The vendor's own conversation isn't told about a rewind. Set - `checkpoints.vendor: false` to turn this off (the editor then waits for - the turn to end). + The vendor's own conversation isn't told about a rewind. To turn this off, + clear **Before subscription turns** in **Settings › Agent › Checkpoints** + (`checkpoints.vendor: false`); the editor then waits for the turn to end. - **Usage.** Tokens and cost the CLI reports are counted once per model call, and also when the turn fails, hits the plan limit or is stopped. - **Switching providers.** A move between providers hands over at most 12,000 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 549e46e2..78d26cc3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -301,7 +301,8 @@ at once. Each run shows as a card in the conversation; click it for the result and changed files, or **Open transcript** for its own conversation. A write subagent's changes reach your project only when the main agent applies its diff, with your usual edit approval. Approvals a subagent needs -appear in this conversation, labelled with its name. +appear in this conversation, labelled with its name. How many run at once, +how many a task may start and their step limit are in **Settings › Agent**. ### Roles: a model for each step @@ -364,8 +365,9 @@ reply. approvals, without selecting a model or sending another model request. Your unsent message stays in the composer. Output and a new check result appear in the conversation; earlier results remain in history. Commands - have a five-minute maximum, or your shorter configured tool timeout. A - passing exit code confirms that check, not every requirement of the task. + have a five-minute maximum, or your shorter tool time limit (**Settings › + Agent**). A passing exit code confirms that check, not every requirement + of the task. - **Review changes** appears only when files changed. It opens the task's review across the whole window: only the files this task changed, each compared with how it was before the task. Files are grouped riskiest @@ -398,9 +400,10 @@ reply. subscription turn, ShadowCode takes a checkpoint of the project. In a Git repository it is a hidden commit under `refs/shadowcode/checkpoints/`; your branch, index and staged changes are untouched. In a folder without Git it - is a copy, for folders up to 5,000 files and 64 MB. A larger folder - shows *Rewind does not cover this command*. Stop the task first (for a - subscription, wait for the turn to end). Rewind asks first and lists the + is a copy, for folders up to 5,000 files and 64 MB (change both in + **Settings › Agent**). A larger folder shows *Rewind does not cover this + command*. Stop the task first (for a subscription, wait for the turn to + end). Rewind asks first and lists the files that will change. Afterwards a divider marks the conversation (*Rewound to here · 2 files restored*) and the notification offers **Undo**, which puts the files back as they were just before the rewind @@ -884,6 +887,46 @@ from the model's prices; models on this computer show `$0 · local`. Click it for input, output and cached tokens, cost, model requests and the last compaction. +## Agent settings + +**Settings › Agent** changes how ShadowCode's own agent works with models on +this computer, API and OpenRouter models, without editing `config.yaml`. +Each setting says what it does, its range and its default. **Reset** puts +one setting back and **Reset all to defaults** the whole page; nothing +changes until you **Save**. A value out of range is marked under its field +and Save waits until it's fixed. If ShadowCode still refuses a save, the +reason is shown above Save. + +- **Limits.** Steps per task (64 by default), the token budget per task + (1,000,000), the autonomy profile, how long one tool call may run (60 + seconds), how much of a tool's output the agent reads (256,000 bytes), and + whether read-only tools of one turn run together. A profile can lower the + token budget, never raise it: *Conservative* stops a task at 64,000 + tokens, *Normal* at 1,000,000 and *Extended* at 4,000,000; *Unlimited* and + *Custom* use the budget as set. +- **Long conversations.** When history is shortened (at 0.7, 70% of the + context), whether the model summarizes what was removed, and how long to + wait for that summary. +- **Recovery.** Provider retries and the wait before the first one, how many + times a task is sent back when it stopped without doing or checking the + work, and the pause when the agent seems stuck. +- **Subagents.** Whether the agent may start subagents, how many run at once + (4) and per task (16), how deep they nest (1: no grandchildren; 0 turns + them off), and the step limit of one whose definition sets none (24). +- **Checkpoints.** Checkpoints before shell commands and before subscription + turns (both on; Rewind needs them), how many are kept per repository + (200), and the largest folder without Git that is copied (5,000 files, + 64 MB). +- **Checks.** Commands that count as checks of a task's work when they run + exactly as written in the project folder, such as `cargo test --offline`. + They ask for approval like any command, and ShadowCode never runs them on + its own. See [verification receipts](VERIFICATION_RECEIPTS.md). + +The page saves only the settings you changed. Subscriptions use only its +checkpoint settings. A trusted project can change Limits, Long conversations +and Recovery for itself in `.shadow/config/config.yaml` (the `agent` group). +To edit by hand, every key is in [config.example.yaml](../config.example.yaml). + ## Code intelligence With a local or OpenRouter model, ShadowCode helps the agent understand the @@ -1073,7 +1116,8 @@ Subscription CLIs use their own sandboxes, not this one. stays in the conversation, so later messages start from it. For models with less than 8K of context, or when the summary fails or takes longer than a minute, a built-in digest is used instead. The full history always stays in - the app. Turn summaries off with `agent.summary_compaction: false`. + the app. Turn summaries off in **Settings › Agent** + (`agent.summary_compaction: false`). - **Shorten on purpose, keep what matters.** Type `/compact` to shorten the conversation when you send your next message, however long it is: every earlier step except the latest answer becomes the summary. Type @@ -1088,7 +1132,7 @@ Subscription CLIs use their own sandboxes, not this one. files and **Only change these**) or **Stop**. Tasks nobody can answer the card for (automations, subagents, and tasks started from an editor or the terminal UI) don't pause: the agent is told to try another way. Turn it - off with `agent.stuck_check: false`. + off in **Settings › Agent** (`agent.stuck_check: false`). - **Heads-ups when a task finishes.** If a task skipped or deleted tests, removed assertions, changed CI or hook settings, switched off a lint or type check in the code, or rewrote test snapshots, the conversation says @@ -1101,14 +1145,16 @@ Subscription CLIs use their own sandboxes, not this one. about file by file, so the conversation names any other file a command changed. Subscriptions can't be stopped mid-turn, so after each turn the conversation names any file they changed elsewhere; **Review** can undo - it. This check needs project checkpoints (`checkpoints.shell` for - commands, `checkpoints.vendor` for subscriptions, both on by default). + it. This check needs project checkpoints (**Settings › Agent › + Checkpoints**: `checkpoints.shell` for commands, `checkpoints.vendor` for + subscriptions, both on by default). Without one, for example in a large folder that isn't under Git, the conversation says the commands or the turn weren't checked. - **Busy or dropped providers.** A request that fails with a rate limit, an overloaded or failing provider, or a connection that drops mid-answer is - sent again, up to three times (`agent.model_retries`), waiting longer each - time or as long as the provider asks. The conversation shows it, for + sent again, up to three times (**Provider retries** in **Settings › + Agent**, `agent.model_retries`), waiting longer each time or as long as + the provider asks. The conversation shows it, for example *Provider busy, retrying (2 of 5) in 4 s…*, and then *The provider was busy; it answered after 2 retries.* A reply that was cut off is thrown away and replaced; tools only run after a complete reply, so nothing runs diff --git a/docs/VERIFICATION_RECEIPTS.md b/docs/VERIFICATION_RECEIPTS.md index 0ce893ca..a4ea81ea 100644 --- a/docs/VERIFICATION_RECEIPTS.md +++ b/docs/VERIFICATION_RECEIPTS.md @@ -15,7 +15,8 @@ originating project/conversation prevents stale submission or attachment. A command is a configured check only when it is launched through the existing explicit test-task action, exactly matches a command discovered from project manifests in the project root, or exactly matches a user-configured `verification.commands` entry in application configuration. Execution still uses the existing trust, shell approval, sandbox, and lifecycle-hook path. Merely containing `test`, `lint`, or a runner name is not sufficient. Options and subdirectory commands that do not exactly match remain ordinary execution evidence; they can be run explicitly as checks through the test-task action. -The optional application configuration field is: +The optional application configuration field is edited under **Checks** in +**Settings › Agent**, or by hand: ```yaml verification: diff --git a/ui/e2e/agentSettings.spec.ts b/ui/e2e/agentSettings.spec.ts new file mode 100644 index 00000000..4d42683c --- /dev/null +++ b/ui/e2e/agentSettings.spec.ts @@ -0,0 +1,174 @@ +import { test, expect, type Page } from "@playwright/test"; +import { installFakeBackend } from "./fakeBackend"; + +// Settings › Agent in the desktop window (fake engine): edit, see the +// inline checks, survive a refused save, and save only what changed. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { stepMs: 60 }); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +type Logged = { method: string; path: string; body: any }; +const configSaves = (page: Page) => + page.evaluate(() => + ( + window as unknown as { __SHADOW_FAKE__: { log: Logged[] } } + ).__SHADOW_FAKE__.log.filter( + (r) => r.method === "PUT" && r.path === "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/api/config", + ), + ); + +async function openAgent(page: Page) { + await page.keyboard.press("Control+,"); + const dialog = page.getByRole("dialog", { name: "Settings" }); + await dialog.getByRole("button", { name: "Agent", exact: true }).click(); + await expect(dialog.getByRole("heading", { name: "Agent" })).toBeVisible(); + return dialog; +} + +test("edits agent settings, checks them inline and saves only what changed", async ({ + page, +}) => { + const dialog = await openAgent(page); + const steps = dialog.getByLabel("Steps per task", { exact: true }); + const save = dialog.getByRole("button", { name: "Save", exact: true }); + // The engine's values, with what each does, its range and default. + await expect(steps).toHaveValue("64"); + await expect(steps).toHaveAccessibleDescription(/1–1,000 · default 64\.$/); + await expect( + dialog.getByLabel("Running at once", { exact: true }), + ).toHaveValue("4"); + await expect( + dialog.getByLabel("Checkpoints kept per repository", { exact: true }), + ).toHaveValue("200"); + await expect(save).toBeDisabled(); + + // Out of range: said under the field, and Save waits. + await steps.fill("1001"); + await expect(steps).toHaveAttribute("aria-invalid", "true"); + await expect(steps).toHaveAccessibleDescription( + /Enter a whole number from 1 to 1,000\.$/, + ); + await expect(dialog.getByRole("status")).toHaveText( + "Fix the setting marked above to save.", + ); + await expect(save).toBeDisabled(); + await steps.fill("120"); + await expect(steps).not.toHaveAttribute("aria-invalid", "true"); + + await dialog + .getByLabel("Shorten history at this share of the context", { exact: true }) + .fill("0.8"); + await dialog + .getByLabel("Pause and ask when the agent seems stuck", { exact: true }) + .uncheck(); + await dialog.getByLabel("Running at once", { exact: true }).fill("6"); + await dialog + .getByLabel("Before subscription turns", { exact: true }) + .uncheck(); + await dialog.getByRole("button", { name: "Add a check" }).click(); + // The new row has focus: type straight into it. + await expect(dialog.getByLabel("Check 1", { exact: true })).toBeFocused(); + await page.keyboard.type("npm test"); + + // Keyboard: Reset puts the default back and returns focus to the field. + const timeout = dialog.getByLabel("Tool time limit", { exact: true }); + await timeout.fill("90"); + await dialog + .getByRole("button", { name: "Reset Tool time limit to its default" }) + .focus(); + await page.keyboard.press("Enter"); + await expect(timeout).toHaveValue("60"); + await expect(timeout).toBeFocused(); + + await page.screenshot({ path: "test-results/settings-agent-edit.png" }); + + // The engine refuses the first save: its reason shows on the page. + await page.evaluate(() => { + const w = window as unknown as { + __SHADOW_TEST_TRANSPORT__: { + request: (p: string, m: string, b: unknown) => Promise; + }; + }; + const bridge = w.__SHADOW_TEST_TRANSPORT__; + const request = bridge.request.bind(bridge); + let refused = false; + bridge.request = async (path, method, body) => { + if (path === "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/api/config" && method === "PUT" && !refused) { + refused = true; + throw new Error( + "/home/dev/.config/shadow-agent/config.yaml needs a fix: model.context_limit must be between 1024 and 4000000 tokens; found 12", + ); + } + return request(path, method, body); + }; + }); + await save.click(); + const refusal = dialog.getByRole("alert"); + await expect(refusal).toHaveText( + /needs a fix: model\.context_limit must be between 1024 and 4000000 tokens; found 12$/, + ); + await expect(save).toHaveAccessibleDescription(/model\.context_limit/); + expect(await configSaves(page)).toEqual([]); + + // Saved on the next try: only the changed keys of the page's groups. + await save.click(); + await expect( + page.getByRole("status").filter({ hasText: "Settings saved" }), + ).toBeVisible(); + await expect(refusal).toHaveCount(0); + const saves = await configSaves(page); + expect(saves).toHaveLength(1); + expect(saves[0].body.values).toEqual({ + agent: { max_steps: 120, compact_ratio: 0.8, stuck_check: false }, + subagents: { max_parallel: 6 }, + checkpoints: { vendor: false }, + verification: { commands: ["npm test"] }, + }); + await expect(save).toBeDisabled(); + + // Reopened, the page shows what the engine now has. + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + const again = await openAgent(page); + await expect(again.getByLabel("Steps per task", { exact: true })).toHaveValue( + "120", + ); + await expect( + again.getByLabel("Running at once", { exact: true }), + ).toHaveValue("6"); + await expect(again.getByLabel("Nesting depth", { exact: true })).toHaveValue( + "1", + ); + await expect( + again.getByLabel("Pause and ask when the agent seems stuck", { + exact: true, + }), + ).not.toBeChecked(); + await expect(again.getByLabel("Check 1", { exact: true })).toHaveValue( + "npm test", + ); + await expect( + again.getByRole("button", { name: "Reset Steps per task to its default" }), + ).toBeVisible(); +}); + +test("the command palette opens Settings › Agent", async ({ page }) => { + await page.keyboard.press("Control+k"); + await page.keyboard.type("limits, retries, subagents"); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog", { name: "Settings" }); + await expect(dialog.getByRole("heading", { name: "Agent" })).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Agent", exact: true }), + ).toHaveAttribute("aria-current", "page"); +}); diff --git a/ui/e2e/fakeBackend.ts b/ui/e2e/fakeBackend.ts index 1e23a62e..6155ca46 100644 --- a/ui/e2e/fakeBackend.ts +++ b/ui/e2e/fakeBackend.ts @@ -372,6 +372,31 @@ export function installFakeBackend(options: FakeOptions = {}) { cli_agents: { enabled: true }, guardian: { enabled: false, interval_sec: 3600 }, limits: { on_limit: "local", fallback_model: "" }, + // The engine's defaults (config.rs). `subagents` is returned only + // once it has been saved; until then the engine uses its defaults. + agent: { + max_steps: 64, + tool_timeout_sec: 60, + parallel_reads: true, + compact_ratio: 0.7, + model_retries: 3, + retry_backoff_sec: 1.0, + max_fix_retries: 3, + max_output_bytes: 256_000, + max_task_tokens: 1_000_000, + autonomy_profile: "normal", + summary_compaction: true, + summary_timeout_sec: 60, + stuck_check: true, + }, + checkpoints: { + shell: true, + vendor: true, + keep: 200, + max_copy_files: 5000, + max_copy_bytes: 67_108_864, + }, + verification: { commands: [] as string[] }, }, vendors: { codex: { diff --git a/ui/e2e/polish.spec.ts b/ui/e2e/polish.spec.ts index c00e6c8c..ebf875b4 100644 --- a/ui/e2e/polish.spec.ts +++ b/ui/e2e/polish.spec.ts @@ -28,6 +28,7 @@ test.afterEach(async ({ page }) => { const SECTIONS = [ "Accounts", "Local models", + "Agent", "Code intelligence", "Rules & skills", "Voice", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 334c3b5d..1cd28175 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -891,13 +891,14 @@ export default function App() { onChanged: () => void roles.reload(), } : undefined, - onSave: async (values) => { + onSave: async (values, options) => { try { await api.saveConfig(values); await ws.reloadConfig(); void picker.reload(); toast("Settings saved", "ok"); } catch (e) { + if (options?.rethrow) throw e; toast(String(e), "err"); } }, diff --git a/ui/src/components/Settings.tsx b/ui/src/components/Settings.tsx index 1c6bdcbf..677cbba5 100644 --- a/ui/src/components/Settings.tsx +++ b/ui/src/components/Settings.tsx @@ -7,6 +7,7 @@ import { VoicePage } from "./settings/VoicePage"; import { LocalModelsPage } from "./settings/LocalModelsPage"; import { AppearancePage, PermissionsPage } from "./settings/PreferencePages"; import { AdvancedPage, type AdvancedTab } from "./settings/AdvancedPage"; +import { AgentPage } from "./settings/AgentPage"; import { RemotePage } from "./settings/RemotePage"; import { AboutPage } from "./settings/AboutPage"; import { DataPage } from "./settings/DataPage"; @@ -17,6 +18,7 @@ export type SettingsSection = | "accounts" | "local" | "roles" + | "agent" | "code" | "rules" | "voice" @@ -32,6 +34,7 @@ const SECTIONS: { id: SettingsSection; label: string }[] = [ { id: "accounts", label: "Accounts" }, { id: "local", label: "Local models" }, { id: "roles", label: "Roles" }, + { id: "agent", label: "Agent" }, { id: "code", label: "Code intelligence" }, { id: "rules", label: "Rules & skills" }, { id: "voice", label: "Voice" }, @@ -43,6 +46,10 @@ const SECTIONS: { id: SettingsSection; label: string }[] = [ { id: "about", label: "About" }, ]; +/** `rethrow`: the page shows a refused save itself, so `onSave` rejects + * with the engine's error instead of showing it as a toast. */ +export type SaveOptions = { rethrow?: boolean }; + /** Settings. Each page saves only its own configuration group. */ export type SettingsProps = { cfg: Record; @@ -54,7 +61,10 @@ export type SettingsProps = { sessionId: string; busy: boolean; onClose: () => void; - onSave: (values: Record) => Promise; + onSave: ( + values: Record, + options?: SaveOptions, + ) => Promise; /** Accounts or local models changed: the picker reloads its rows. */ onCatalogChanged: () => void; /** A free model finished downloading on the Local models page. */ @@ -136,6 +146,7 @@ export function Settings({

Open a project to choose its roles.

))} + {section === "agent" && } {section === "code" && } {section === "rules" && } {section === "voice" && } diff --git a/ui/src/components/settings/AgentPage.css b/ui/src/components/settings/AgentPage.css new file mode 100644 index 00000000..a7a4c135 --- /dev/null +++ b/ui/src/components/settings/AgentPage.css @@ -0,0 +1,84 @@ +/* Settings › Agent. */ +.agent-group { + border: 0; + margin: 0; + padding: 0; + min-width: 0; + display: grid; + gap: 4px; +} +.agent-group legend { + padding: 0; + margin: 10px 0 2px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.agent-group > .hint { + margin: 0 0 4px; +} +.agent-field { + margin: 4px 0 6px; +} +.agent-field .hint, +.agent-field .agent-error { + margin: 0; + font-size: 12px; +} +.agent-field > .agent-input-row .check { + margin: 0; +} +.agent-input-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} +.agent-page .field .agent-input-row input, +.agent-page .field .agent-input-row select { + width: auto; + flex: 0 1 220px; + min-width: 0; + font-variant-numeric: tabular-nums; +} +.agent-page .field .agent-input-row select { + flex-basis: 320px; +} +.agent-unit { + color: var(--muted); + font-size: 12px; +} +.agent-page input[aria-invalid="true"], +.agent-page select[aria-invalid="true"] { + border-color: var(--danger); +} +.agent-checks { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} +.agent-checks input { + flex: 1 1 260px; + min-width: 0; + font-family: var(--mono); + font-size: 12px; + background: var(--surface-2); + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 7px 10px; +} +.agent-checks input:focus { + border-color: var(--border-strong); + background: var(--surface); +} +.agent-checks .agent-error { + margin: 4px 0 0; + font-size: 12px; +} +.agent-foot-note { + margin: 0 auto 0 0; +} diff --git a/ui/src/components/settings/AgentPage.test.tsx b/ui/src/components/settings/AgentPage.test.tsx new file mode 100644 index 00000000..3b9b193f --- /dev/null +++ b/ui/src/components/settings/AgentPage.test.tsx @@ -0,0 +1,257 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { ApiError } from "../../lib/transport"; +import { Settings } from "../Settings"; +import { AgentPage } from "./AgentPage"; + +afterEach(cleanup); + +const CFG = { + agent: { + max_steps: 64, + tool_timeout_sec: 60, + parallel_reads: true, + compact_ratio: 0.7, + model_retries: 3, + retry_backoff_sec: 1.0, + max_fix_retries: 3, + max_output_bytes: 256_000, + max_task_tokens: 1_000_000, + autonomy_profile: "normal", + summary_compaction: true, + summary_timeout_sec: 60, + stuck_check: true, + }, + checkpoints: { + shell: true, + vendor: true, + keep: 200, + max_copy_files: 5000, + max_copy_bytes: 67_108_864, + }, + verification: { commands: ["cargo test --offline"] }, +}; + +const input = (label: string) => + screen.getByLabelText(label) as HTMLInputElement; +const describedBy = (element: HTMLElement) => + (element.getAttribute("aria-describedby") || "") + .split(" ") + .map((id) => document.getElementById(id)?.textContent || "") + .join(" | "); +const saveButton = () => + screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; + +it("shows each group with the saved values, what they do, ranges and defaults", () => { + render(); + for (const legend of [ + "Limits", + "Long conversations", + "Recovery", + "Subagents", + "Checkpoints", + "Checks", + ]) + expect(screen.getByRole("group", { name: legend })).toBeTruthy(); + expect(input("Steps per task").value).toBe("64"); + expect(describedBy(input("Steps per task"))).toBe( + "Model turns one task may take. At the limit the task stops so you can review its changes and continue. 1–1,000 · default 64.", + ); + expect(describedBy(input("Tool time limit"))).toContain( + "1–3,600 seconds · default 60.", + ); + expect(input("Shorten history at this share of the context").value).toBe( + "0.7", + ); + expect( + (screen.getByLabelText("Autonomy profile") as HTMLSelectElement).value, + ).toBe("normal"); + // `subagents` is not in the configuration until it is saved: defaults. + expect(input("Running at once").value).toBe("4"); + expect(input("Nesting depth").value).toBe("1"); + expect(input("Let the agent start subagents").checked).toBe(true); + expect(input("Bytes copied in a folder without Git").value).toBe("67108864"); + expect(input("Check 1").value).toBe("cargo test --offline"); + // Nothing changed: nothing to save, nothing to reset. + expect(saveButton().disabled).toBe(true); + expect( + screen.queryByRole("button", { name: /^Reset .* to its default$/ }), + ).toBeNull(); +}); + +it("validates inline, then saves only the changed keys of its own groups", async () => { + const onSave = vi.fn(async () => {}); + render(); + const steps = input("Steps per task"); + fireEvent.change(steps, { target: { value: "0" } }); + expect(steps.getAttribute("aria-invalid")).toBe("true"); + expect(describedBy(steps)).toContain("Enter a whole number from 1 to 1,000."); + fireEvent.change(input("Shorten history at this share of the context"), { + target: { value: "0.99" }, + }); + expect(screen.getByRole("status").textContent).toBe( + "Fix the 2 settings marked above to save.", + ); + expect(saveButton().disabled).toBe(true); + + fireEvent.change(steps, { target: { value: "120" } }); + expect(steps.getAttribute("aria-invalid")).toBeNull(); + fireEvent.change(input("Shorten history at this share of the context"), { + target: { value: "0.8" }, + }); + fireEvent.click(input("Pause and ask when the agent seems stuck")); + fireEvent.change(input("Running at once"), { target: { value: "6" } }); + fireEvent.click(input("Before subscription turns")); + fireEvent.change(screen.getByLabelText("Autonomy profile"), { + target: { value: "extended" }, + }); + expect(screen.queryByRole("status")).toBeNull(); + fireEvent.click(saveButton()); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledWith( + { + agent: { + max_steps: 120, + compact_ratio: 0.8, + stuck_check: false, + autonomy_profile: "extended", + }, + subagents: { max_parallel: 6 }, + checkpoints: { vendor: false }, + }, + { rethrow: true }, + ); +}); + +it("resets one setting or all of them to the defaults", async () => { + render( + , + ); + const reset = screen.getByRole("button", { + name: "Reset Steps per task to its default", + }); + fireEvent.click(reset); + expect(input("Steps per task").value).toBe("64"); + expect(document.activeElement).toBe(input("Steps per task")); + expect( + screen.queryByRole("button", { + name: "Reset Steps per task to its default", + }), + ).toBeNull(); + // The checkbox still differs; Reset all puts it and the checks back too. + expect(input("Pause and ask when the agent seems stuck").checked).toBe(false); + fireEvent.click( + screen.getByRole("button", { name: "Reset all to defaults" }), + ); + expect(input("Pause and ask when the agent seems stuck").checked).toBe(true); + expect(screen.queryByLabelText("Check 1")).toBeNull(); + expect( + ( + screen.getByRole("button", { + name: "Reset all to defaults", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + // Focus moves to Save, which now saves the defaults. + await waitFor(() => expect(document.activeElement).toBe(saveButton())); + expect(saveButton().disabled).toBe(false); +}); + +it("edits the list of checks", async () => { + const onSave = vi.fn(async () => {}); + render(); + fireEvent.click(screen.getByRole("button", { name: "Add a check" })); + const second = input("Check 2"); + await waitFor(() => expect(document.activeElement).toBe(second)); + fireEvent.change(second, { target: { value: "x".repeat(64_001) } }); + expect(second.getAttribute("aria-invalid")).toBe("true"); + expect(describedBy(second)).toBe("A command can be at most 64,000 bytes."); + expect(saveButton().disabled).toBe(true); + fireEvent.change(second, { target: { value: " npm run lint " } }); + fireEvent.click(screen.getByRole("button", { name: "Remove check 1" })); + expect(input("Check 1").value).toBe(" npm run lint "); + fireEvent.click(saveButton()); + await waitFor(() => + expect(onSave).toHaveBeenCalledWith( + { verification: { commands: ["npm run lint"] } }, + { rethrow: true }, + ), + ); +}); + +it("shows the engine's reason when it refuses a save, on the setting it names", async () => { + const onSave = vi.fn(async () => { + throw new ApiError("checkpoints.keep must be between 1 and 10000"); + }); + render(); + const keep = input("Checkpoints kept per repository"); + fireEvent.change(keep, { target: { value: "300" } }); + fireEvent.click(saveButton()); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toBe( + "checkpoints.keep must be between 1 and 10000", + ); + expect(keep.getAttribute("aria-invalid")).toBe("true"); + expect(describedBy(keep)).toContain( + "checkpoints.keep must be between 1 and 10000", + ); + expect(describedBy(saveButton())).toBe( + "checkpoints.keep must be between 1 and 10000", + ); + // Editing clears the refusal; the value stays for another try. + fireEvent.change(keep, { target: { value: "250" } }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(keep.getAttribute("aria-invalid")).toBeNull(); +}); + +it("says when the autonomy profile caps a larger token budget", () => { + render(); + const budget = input("Token budget per task"); + fireEvent.change(budget, { target: { value: "3000000" } }); + expect(describedBy(budget)).toContain( + "With the Normal profile a task still stops at 1,000,000 tokens.", + ); + fireEvent.change(screen.getByLabelText("Autonomy profile"), { + target: { value: "extended" }, + }); + expect(describedBy(budget)).not.toContain("still stops"); +}); + +it("is a Settings section", () => { + render( + {})} + onCatalogChanged={vi.fn()} + onToast={vi.fn()} + onOpenSession={vi.fn()} + onSkillsChanged={async () => {}} + onUseSkill={vi.fn()} + />, + ); + const nav = screen.getByRole("navigation", { name: "Settings sections" }); + expect( + within(nav) + .getByRole("button", { name: "Agent" }) + .getAttribute("aria-current"), + ).toBe("page"); + expect(screen.getByRole("heading", { name: "Agent" })).toBeTruthy(); +}); diff --git a/ui/src/components/settings/AgentPage.tsx b/ui/src/components/settings/AgentPage.tsx new file mode 100644 index 00000000..342b9597 --- /dev/null +++ b/ui/src/components/settings/AgentPage.tsx @@ -0,0 +1,412 @@ +import { useId, useState } from "react"; +import { readableError } from "../../lib/transport"; +import { + AGENT_FIELDS, + AGENT_SECTIONS, + MAX_CHECKS, + agentPatch, + checkErrors, + checksFrom, + defaultDraft, + draftFrom, + fieldError, + fieldId, + fieldNamedIn, + formatNumber, + parsed, + profileCapNote, + rangeText, + type AgentField, + type Draft, +} from "../../lib/agentSettings"; +import type { SaveOptions } from "../Settings"; +import "./AgentPage.css"; + +type Save = ( + values: Record, + options?: SaveOptions, +) => Promise; + +const CHECKS_ID = "verification.commands"; + +/** Settings › Agent: how ShadowCode's own agent works (limits, long + * conversations, recovery), its subagents, project checkpoints and the + * commands that count as checks. Saves only the keys changed in the agent, + * subagents, checkpoints and verification groups. */ +export function AgentPage({ + cfg, + onSave, +}: { + cfg: Record; + onSave: Save; +}) { + const uid = useId().replace(/:/g, ""); + const [draft, setDraft] = useState(() => draftFrom(cfg)); + const [checks, setChecks] = useState(() => checksFrom(cfg)); + const [saving, setSaving] = useState(false); + const [refused, setRefused] = useState(""); + const [refusedField, setRefusedField] = useState(null); + + const domId = (id: string) => `${uid}-${id.replace(".", "-")}`; + const saveErrorId = `${uid}-save-error`; + const errors = Object.fromEntries( + AGENT_FIELDS.map((f) => [fieldId(f), fieldError(f, draft[fieldId(f)])]), + ); + const checkProblems = checkErrors(checks); + const problems = + Object.values(errors).filter(Boolean).length + + checkProblems.rows.filter(Boolean).length + + (checkProblems.list ? 1 : 0); + const patch = agentPatch(cfg, draft, checks); + const changed = Object.keys(patch).length > 0; + const defaults = defaultDraft(); + const atDefaults = + AGENT_FIELDS.every( + (f) => parsed(f, draft[fieldId(f)]) === parsed(f, defaults[fieldId(f)]), + ) && checks.every((row) => !row.trim()); + + const edited = () => { + setRefused(""); + setRefusedField(null); + }; + const set = (id: string, value: string | boolean) => { + setDraft((d) => ({ ...d, [id]: value })); + edited(); + }; + const reset = (field: AgentField) => { + const id = fieldId(field); + set(id, defaults[id]); + document.getElementById(domId(id))?.focus(); + }; + const setCheck = (index: number, value: string) => { + setChecks((rows) => rows.map((row, i) => (i === index ? value : row))); + edited(); + }; + const removeCheck = (index: number) => { + setChecks((rows) => rows.filter((_, i) => i !== index)); + edited(); + // Keep focus in the list: the next row, the previous one, or Add. + window.setTimeout(() => { + const rows = document.querySelectorAll( + `[data-check-row="${uid}"]`, + ); + ( + rows[Math.min(index, rows.length - 1)] ?? + document.getElementById(`${uid}-add-check`) + )?.focus(); + }); + }; + const addCheck = () => { + setChecks((rows) => [...rows, ""]); + edited(); + window.setTimeout(() => { + const rows = document.querySelectorAll( + `[data-check-row="${uid}"]`, + ); + rows[rows.length - 1]?.focus(); + }); + }; + + async function save() { + setSaving(true); + setRefused(""); + setRefusedField(null); + try { + await onSave(patch, { rethrow: true }); + } catch (e) { + const text = readableError(e) || "ShadowCode could not save these."; + setRefused(text); + setRefusedField(fieldNamedIn(text)); + } finally { + setSaving(false); + } + } + + const capNote = profileCapNote(draft); + return ( +
+

Agent

+

+ How ShadowCode's own agent works with models on this computer, API + and OpenRouter models. Subscriptions such as Codex and Claude Code run + their own agent and use only the checkpoint settings here. +

+

+ These apply to every project. A trusted project can change Limits, Long + conversations and Recovery for itself in{" "} + .shadow/config/config.yaml. +

+ {AGENT_SECTIONS.map((section) => ( +
+ {section.title} + {section.intro &&

{section.intro}

} + {section.fields.map((field) => { + const id = fieldId(field); + const note = id === "agent.max_task_tokens" ? capNote : ""; + return ( + set(id, value)} + onReset={() => reset(field)} + /> + ); + })} +
+ ))} +
+ Checks +

+ Commands that count as checks of a task's work when they run + exactly as written in the project folder, such as{" "} + cargo test --offline. They ask for approval like any + command, and ShadowCode never runs them on its own. Commands found in + the project's manifests, such as npm test, already + count. Up to {MAX_CHECKS} · none by default. +

+ {checks.length > 0 && ( +
    + {checks.map((row, index) => { + const rowError = checkProblems.rows[index]; + const errorId = `${uid}-check-${index}-error`; + const describedBy = [ + rowError ? errorId : "", + refusedField === CHECKS_ID ? saveErrorId : "", + ] + .filter(Boolean) + .join(" "); + return ( +
  1. +
    + setCheck(index, e.target.value)} + /> + +
    + {rowError && ( +

    + {rowError} +

    + )} +
  2. + ); + })} +
+ )} + {checkProblems.list && ( +

+ {checkProblems.list} +

+ )} +
+ +
+
+ {refused && ( + + )} +
+ {problems > 0 && ( +

+ {problems === 1 + ? "Fix the setting marked above to save." + : `Fix the ${formatNumber(problems)} settings marked above to save.`} +

+ )} + + +
+
+ ); +} + +/** One setting: its control, what it does, its range and default, a Reset + * when it differs from the default, and its error. */ +function FieldRow({ + field, + inputId, + value, + error, + note, + refusedBy, + atDefault, + onChange, + onReset, +}: { + field: AgentField; + inputId: string; + value: string | boolean; + error: string; + note: string; + /** The id of a refused save's message that names this setting. */ + refusedBy: string; + atDefault: boolean; + onChange: (value: string | boolean) => void; + onReset: () => void; +}) { + const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + const noteId = `${inputId}-note`; + const describedBy = [ + hintId, + error ? errorId : "", + note ? noteId : "", + refusedBy, + ] + .filter(Boolean) + .join(" "); + const invalid = error || refusedBy ? true : undefined; + const resetButton = !atDefault && ( + + ); + const hint = ( +

+ {field.hint} {rangeText(field)} +

+ ); + if (field.kind === "bool") + return ( +
+
+ + {resetButton} +
+ {hint} +
+ ); + return ( +
+ +
+ {field.kind === "choice" ? ( + + ) : ( + onChange(e.target.value)} + /> + )} + {field.kind !== "choice" && field.unit && ( + + )} + {resetButton} +
+ {hint} + {error && ( +

+ {error} +

+ )} + {note && ( +

+ {note} +

+ )} +
+ ); +} diff --git a/ui/src/components/shell/palette.test.ts b/ui/src/components/shell/palette.test.ts index 68a3807b..dcb353f9 100644 --- a/ui/src/components/shell/palette.test.ts +++ b/ui/src/components/shell/palette.test.ts @@ -7,6 +7,7 @@ const SETTINGS_SECTIONS = [ "accounts", "local", "roles", + "agent", "code", "rules", "voice", diff --git a/ui/src/components/shell/palette.ts b/ui/src/components/shell/palette.ts index c56be544..a3e1073d 100644 --- a/ui/src/components/shell/palette.ts +++ b/ui/src/components/shell/palette.ts @@ -68,6 +68,11 @@ export function paletteItems(a: PaletteActions): PaletteItem[] { label: "Roles · a model to plan, implement, review and explore", run: () => a.settings("roles"), }, + { + id: "agent", + label: "Agent · limits, retries, subagents and checks", + run: () => a.settings("agent"), + }, { id: "code", label: "Code intelligence · language servers and search", diff --git a/ui/src/lib/agentSettings.test.ts b/ui/src/lib/agentSettings.test.ts new file mode 100644 index 00000000..f154cd86 --- /dev/null +++ b/ui/src/lib/agentSettings.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_FIELDS, + agentPatch, + checkErrors, + defaultDraft, + draftFrom, + fieldError, + fieldId, + fieldNamedIn, + profileCapNote, + rangeText, + type AgentField, + type NumberField, +} from "./agentSettings"; + +const field = (id: string) => + AGENT_FIELDS.find((f) => fieldId(f) === id) as AgentField; + +describe("Settings › Agent fields mirror the engine", () => { + it("has the engine's defaults (config.rs, subagents.rs, checkpoint.rs)", () => { + expect(defaultDraft()).toEqual({ + "agent.max_steps": "64", + "agent.max_task_tokens": "1000000", + "agent.autonomy_profile": "normal", + "agent.tool_timeout_sec": "60", + "agent.max_output_bytes": "256000", + "agent.parallel_reads": true, + "agent.compact_ratio": "0.7", + "agent.summary_compaction": true, + "agent.summary_timeout_sec": "60", + "agent.model_retries": "3", + "agent.retry_backoff_sec": "1", + "agent.max_fix_retries": "3", + "agent.stuck_check": true, + "subagents.enabled": true, + "subagents.max_parallel": "4", + "subagents.max_depth": "1", + "subagents.max_turns": "24", + "subagents.max_per_task": "16", + "checkpoints.shell": true, + "checkpoints.vendor": true, + "checkpoints.keep": "200", + "checkpoints.max_copy_files": "5000", + "checkpoints.max_copy_bytes": "67108864", + }); + }); + + it("has the engine's ranges and number types", () => { + const ranges = Object.fromEntries( + AGENT_FIELDS.filter( + (f): f is NumberField => f.kind === "int" || f.kind === "float", + ).map((f) => [fieldId(f), [f.kind, f.min, f.max]]), + ); + expect(ranges).toEqual({ + "agent.max_steps": ["int", 1, 1000], + "agent.max_task_tokens": ["int", 1, Number.MAX_SAFE_INTEGER], + "agent.tool_timeout_sec": ["int", 1, 3600], + "agent.max_output_bytes": ["int", 4096, 4_000_000], + "agent.compact_ratio": ["float", 0.2, 0.95], + "agent.summary_timeout_sec": ["int", 5, 600], + "agent.model_retries": ["int", 0, 10], + "agent.retry_backoff_sec": ["float", 0, 30], + "agent.max_fix_retries": ["int", 0, 10], + "subagents.max_parallel": ["int", 1, 8], + "subagents.max_depth": ["int", 0, 3], + "subagents.max_turns": ["int", 1, 200], + "subagents.max_per_task": ["int", 1, 64], + "checkpoints.keep": ["int", 1, 10_000], + "checkpoints.max_copy_files": ["int", 0, 200_000], + "checkpoints.max_copy_bytes": ["int", 0, 1_073_741_824], + }); + const profile = field("agent.autonomy_profile"); + expect( + profile.kind === "choice" && profile.options.map((o) => o.value), + ).toEqual(["normal", "conservative", "extended", "unlimited", "custom"]); + }); + + it("accepts each range's ends and refuses just outside them", () => { + for (const f of AGENT_FIELDS) { + if (f.kind !== "int" && f.kind !== "float") continue; + const step = f.kind === "int" ? 1 : 0.01; + expect(fieldError(f, String(f.min)), fieldId(f)).toBe(""); + expect(fieldError(f, String(f.max)), fieldId(f)).toBe(""); + expect(fieldError(f, String(f.default)), fieldId(f)).toBe(""); + expect(fieldError(f, String(f.min - step)), fieldId(f)).not.toBe(""); + if (f.max < Number.MAX_SAFE_INTEGER) + expect(fieldError(f, String(f.max + step)), fieldId(f)).not.toBe(""); + expect(fieldError(f, ""), fieldId(f)).not.toBe(""); + } + expect(fieldError(field("agent.max_steps"), "1.5")).toBe( + "Enter a whole number from 1 to 1,000.", + ); + expect(fieldError(field("agent.tool_timeout_sec"), "0")).toBe( + "Enter a whole number from 1 to 3,600 seconds.", + ); + expect(fieldError(field("agent.compact_ratio"), "0.96")).toBe( + "Enter a number from 0.2 to 0.95.", + ); + expect(fieldError(field("agent.compact_ratio"), "0.725")).toBe(""); + expect(fieldError(field("agent.max_task_tokens"), "0")).toBe( + "Enter a whole number of at least 1.", + ); + expect(fieldError(field("agent.autonomy_profile"), "wild")).toMatch( + /^Choose one of Normal, Conservative/, + ); + }); + + it("shows each range and default in words", () => { + expect(rangeText(field("agent.max_steps"))).toBe("1–1,000 · default 64."); + expect(rangeText(field("agent.tool_timeout_sec"))).toBe( + "1–3,600 seconds · default 60.", + ); + expect(rangeText(field("agent.max_task_tokens"))).toBe( + "At least 1 · default 1,000,000.", + ); + expect(rangeText(field("checkpoints.max_copy_bytes"))).toBe( + "0–1,073,741,824 bytes (1 GiB) · default 67,108,864 (64 MiB).", + ); + expect(rangeText(field("agent.stuck_check"))).toBe("On by default."); + expect(rangeText(field("agent.autonomy_profile"))).toBe("Default: Normal."); + }); +}); + +describe("saving", () => { + const cfg = { + agent: { max_steps: 64, compact_ratio: 0.7, stuck_check: true }, + checkpoints: { keep: 200 }, + verification: { commands: ["cargo test"] }, + }; + + it("sends nothing when nothing changed, and only changed keys otherwise", () => { + expect(agentPatch(cfg, draftFrom(cfg), ["cargo test"])).toEqual({}); + const draft = { + ...draftFrom(cfg), + "agent.max_steps": "120", + "agent.compact_ratio": "0.80", + "agent.stuck_check": false, + "subagents.max_parallel": "6", + }; + expect(agentPatch(cfg, draft, ["cargo test", " npm test ", ""])).toEqual({ + agent: { max_steps: 120, compact_ratio: 0.8, stuck_check: false }, + subagents: { max_parallel: 6 }, + verification: { commands: ["cargo test", "npm test"] }, + }); + }); + + it("uses the defaults for a group the configuration leaves out", () => { + const draft = draftFrom({}); + expect(draft["subagents.max_depth"]).toBe("1"); + expect(draft["checkpoints.shell"]).toBe(true); + expect(agentPatch({}, draft, [])).toEqual({}); + }); + + it("finds the setting a refused save names", () => { + expect( + fieldNamedIn("agent.max_steps must be between 1 and 1000; found 0"), + ).toBe("agent.max_steps"); + expect(fieldNamedIn("checkpoints.keep must be between 1 and 10000")).toBe( + "checkpoints.keep", + ); + expect( + fieldNamedIn("`subagents.max_turns` has a value ShadowCode can't read"), + ).toBe("subagents.max_turns"); + expect( + fieldNamedIn("verification.commands holds at most 64 commands"), + ).toBe("verification.commands"); + expect(fieldNamedIn("Configuration exceeds 1 MB")).toBeNull(); + }); + + it("explains when a profile caps a larger token budget", () => { + const draft = defaultDraft(); + expect(profileCapNote(draft)).toBe(""); + expect( + profileCapNote({ ...draft, "agent.max_task_tokens": "2000000" }), + ).toBe( + "With the Normal profile a task still stops at 1,000,000 tokens. Choose Extended, Unlimited or Custom to use more.", + ); + expect( + profileCapNote({ + ...draft, + "agent.autonomy_profile": "extended", + "agent.max_task_tokens": "5000000", + }), + ).toBe( + "With the Extended profile a task still stops at 4,000,000 tokens. Choose Unlimited or Custom to use more.", + ); + expect( + profileCapNote({ + ...draft, + "agent.autonomy_profile": "custom", + "agent.max_task_tokens": "5000000", + }), + ).toBe(""); + }); + + it("checks commands the way the engine does", () => { + expect(checkErrors(["cargo test", ""])).toEqual({ + rows: ["", ""], + list: "", + }); + // 64,000 bytes, not characters: "é" is two bytes. + expect(checkErrors(["é".repeat(32_000)]).rows[0]).toBe(""); + expect(checkErrors(["é".repeat(32_001)]).rows[0]).toBe( + "A command can be at most 64,000 bytes.", + ); + expect(checkErrors(["echo a\0b"]).rows[0]).toBe( + "A command cannot contain a NUL character.", + ); + expect( + checkErrors(Array.from({ length: 65 }, (_, i) => `check ${i}`)).list, + ).toBe("Keep at most 64 checks."); + }); +}); diff --git a/ui/src/lib/agentSettings.ts b/ui/src/lib/agentSettings.ts new file mode 100644 index 00000000..fdfb6bc0 --- /dev/null +++ b/ui/src/lib/agentSettings.ts @@ -0,0 +1,495 @@ +/** Settings › Agent: the `agent`, `subagents`, `checkpoints` and + * `verification` configuration groups. Defaults, types and ranges mirror the + * engine (native/core/src/config.rs, subagents.rs, checkpoint.rs and + * verification.rs); config.example.yaml documents the same keys. */ + +export type AgentGroup = "agent" | "subagents" | "checkpoints"; + +type Base = { + group: AgentGroup; + key: string; + label: string; + /** One friendly line; the range and default are added after it. */ + hint: string; +}; +export type NumberField = Base & { + kind: "int" | "float"; + min: number; + max: number; + default: number; + /** Shown after the range, such as "seconds" or "bytes". */ + unit?: string; + /** Replace the generated range and default text where words help. */ + range?: string; + defaultLabel?: string; + /** The arrow-key step of a decimal field. */ + step?: number; +}; +export type BoolField = Base & { kind: "bool"; default: boolean }; +export type ChoiceField = Base & { + kind: "choice"; + default: string; + options: { value: string; label: string }[]; +}; +export type AgentField = NumberField | BoolField | ChoiceField; +export type AgentSection = { + id: string; + title: string; + intro?: string; + fields: AgentField[]; +}; + +/** Profiles that cap the token budget (autonomy.rs `caps_for`). Unlimited + * and custom use the configured budget as it is. */ +export const PROFILE_TOKEN_CAPS: Record = { + conservative: 64_000, + normal: 1_000_000, + extended: 4_000_000, +}; + +const PROFILE_LABELS: Record = { + normal: "Normal", + conservative: "Conservative", + extended: "Extended", + unlimited: "Unlimited", + custom: "Custom", +}; + +export const formatNumber = (value: number) => value.toLocaleString("en-US"); + +export const AGENT_SECTIONS: AgentSection[] = [ + { + id: "limits", + title: "Limits", + fields: [ + { + kind: "int", + group: "agent", + key: "max_steps", + label: "Steps per task", + hint: "Model turns one task may take. At the limit the task stops so you can review its changes and continue.", + min: 1, + max: 1000, + default: 64, + }, + { + kind: "int", + group: "agent", + key: "max_task_tokens", + label: "Token budget per task", + hint: "Tokens one task may use before it stops. Changes it made are kept for review.", + min: 1, + max: Number.MAX_SAFE_INTEGER, + range: "At least 1", + default: 1_000_000, + }, + { + kind: "choice", + group: "agent", + key: "autonomy_profile", + label: "Autonomy profile", + hint: "A profile can lower the token budget, never raise it.", + default: "normal", + options: Object.keys(PROFILE_LABELS).map((value) => ({ + value, + label: + value in PROFILE_TOKEN_CAPS + ? `${PROFILE_LABELS[value]} · stops at ${formatNumber(PROFILE_TOKEN_CAPS[value])} tokens` + : `${PROFILE_LABELS[value]} · uses the token budget`, + })), + }, + { + kind: "int", + group: "agent", + key: "tool_timeout_sec", + label: "Tool time limit", + hint: "Longest one tool call, such as a shell command, may run before it is stopped.", + min: 1, + max: 3600, + unit: "seconds", + default: 60, + }, + { + kind: "int", + group: "agent", + key: "max_output_bytes", + label: "Tool output kept per call", + hint: "Output of one tool call the agent gets to read; anything longer is cut.", + min: 4096, + max: 4_000_000, + unit: "bytes", + default: 256_000, + }, + { + kind: "bool", + group: "agent", + key: "parallel_reads", + label: "Run read-only tools of one turn together", + hint: "Reads and searches the model asks for in the same turn run at the same time.", + default: true, + }, + ], + }, + { + id: "context", + title: "Long conversations", + fields: [ + { + kind: "float", + group: "agent", + key: "compact_ratio", + label: "Shorten history at this share of the context", + hint: "When a conversation fills this share of the model's context, its oldest steps are removed. 0.7 is 70%.", + min: 0.2, + max: 0.95, + default: 0.7, + step: 0.05, + }, + { + kind: "bool", + group: "agent", + key: "summary_compaction", + label: "Summarize removed steps with the model", + hint: "The same model writes a short summary of what was removed. Off, or when it fails, a built-in digest is used.", + default: true, + }, + { + kind: "int", + group: "agent", + key: "summary_timeout_sec", + label: "Longest wait for the summary", + hint: "After this, the built-in digest is used instead.", + min: 5, + max: 600, + unit: "seconds", + default: 60, + }, + ], + }, + { + id: "recovery", + title: "Recovery", + fields: [ + { + kind: "int", + group: "agent", + key: "model_retries", + label: "Provider retries", + hint: "Times a request is sent again when the provider is busy or failing, or the connection drops mid-answer.", + min: 0, + max: 10, + default: 3, + }, + { + kind: "float", + group: "agent", + key: "retry_backoff_sec", + label: "First retry wait", + hint: "Each later wait doubles, with a little randomness. A wait the provider asks for wins.", + min: 0, + max: 30, + unit: "seconds", + default: 1, + step: 0.5, + }, + { + kind: "int", + group: "agent", + key: "max_fix_retries", + label: "Fix attempts per task", + hint: "Times a task is sent back when it stopped without doing or checking the work, or a completion check failed. When none are left, the task fails.", + min: 0, + max: 10, + default: 3, + }, + { + kind: "bool", + group: "agent", + key: "stuck_check", + label: "Pause and ask when the agent seems stuck", + hint: "The same command failing the same way three times, or a file changed back and forth, pauses the task.", + default: true, + }, + ], + }, + { + id: "subagents", + title: "Subagents", + intro: + "Helpers the agent starts for focused work, such as searching, planning or reviewing. Plan → Implement → Review runs its roles either way.", + fields: [ + { + kind: "bool", + group: "subagents", + key: "enabled", + label: "Let the agent start subagents", + hint: "Offers the agent its spawn_agent tool.", + default: true, + }, + { + kind: "int", + group: "subagents", + key: "max_parallel", + label: "Running at once", + hint: "Subagents of one task that run at the same time.", + min: 1, + max: 8, + default: 4, + }, + { + kind: "int", + group: "subagents", + key: "max_depth", + label: "Nesting depth", + hint: "1 lets subagents run but not start their own; 0 turns them off.", + min: 0, + max: 3, + default: 1, + }, + { + kind: "int", + group: "subagents", + key: "max_turns", + label: "Steps per subagent", + hint: "For a subagent whose definition sets none. Never more than Steps per task.", + min: 1, + max: 200, + default: 24, + }, + { + kind: "int", + group: "subagents", + key: "max_per_task", + label: "Subagents per task", + hint: "Subagents one task may start in total.", + min: 1, + max: 64, + default: 16, + }, + ], + }, + { + id: "checkpoints", + title: "Checkpoints", + intro: + "Snapshots of the project that let Rewind undo what commands and subscriptions changed.", + fields: [ + { + kind: "bool", + group: "checkpoints", + key: "shell", + label: "Before shell commands", + hint: "Before each command the agent runs that can change files.", + default: true, + }, + { + kind: "bool", + group: "checkpoints", + key: "vendor", + label: "Before subscription turns", + hint: "Before each turn of Codex, Claude Code and the other subscriptions.", + default: true, + }, + { + kind: "int", + group: "checkpoints", + key: "keep", + label: "Checkpoints kept per repository", + hint: "Hidden Git checkpoints kept under refs/shadowcode/checkpoints.", + min: 1, + max: 10_000, + default: 200, + }, + { + kind: "int", + group: "checkpoints", + key: "max_copy_files", + label: "Files copied in a folder without Git", + hint: "Without Git the folder is copied; a folder with more files is not covered.", + min: 0, + max: 200_000, + unit: "files", + default: 5000, + }, + { + kind: "int", + group: "checkpoints", + key: "max_copy_bytes", + label: "Bytes copied in a folder without Git", + hint: "A folder without Git that is larger is not covered.", + min: 0, + max: 1024 * 1024 * 1024, + unit: "bytes", + range: "0–1,073,741,824 bytes (1 GiB)", + default: 64 * 1024 * 1024, + defaultLabel: "67,108,864 (64 MiB)", + }, + ], + }, +]; + +export const AGENT_FIELDS: AgentField[] = AGENT_SECTIONS.flatMap( + (s) => s.fields, +); +export const fieldId = (f: { group: string; key: string }) => + `${f.group}.${f.key}`; + +/** `verification.commands` (verification.rs `CheckConfig::validate`). */ +export const MAX_CHECKS = 64; +export const MAX_CHECK_BYTES = 64_000; + +/** "1–1,000 seconds · default 60" */ +export function rangeText(field: AgentField): string { + if (field.kind === "bool") + return field.default ? "On by default." : "Off by default."; + if (field.kind === "choice") { + const label = field.options + .find((o) => o.value === field.default) + ?.label.split(" · ")[0]; + return `Default: ${label}.`; + } + const range = + field.range ?? + `${formatNumber(field.min)}–${formatNumber(field.max)}${field.unit ? ` ${field.unit}` : ""}`; + return `${range} · default ${field.defaultLabel ?? formatNumber(field.default)}.`; +} + +export type Draft = Record; + +const groupOf = (cfg: Record, group: string) => { + const value = cfg[group]; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +}; + +/** The value the engine uses now: the saved one, else the default (a group + * missing from the configuration, such as `subagents`, uses defaults). */ +function current(cfg: Record, field: AgentField) { + const value = groupOf(cfg, field.group)[field.key]; + return value === undefined || value === null ? field.default : value; +} + +export function draftFrom(cfg: Record): Draft { + const draft: Draft = {}; + for (const field of AGENT_FIELDS) { + const value = current(cfg, field); + draft[fieldId(field)] = + field.kind === "bool" ? value === true : String(value); + } + return draft; +} + +export function defaultDraft(): Draft { + const draft: Draft = {}; + for (const field of AGENT_FIELDS) + draft[fieldId(field)] = + field.kind === "bool" ? field.default : String(field.default); + return draft; +} + +export function checksFrom(cfg: Record): string[] { + const commands = groupOf(cfg, "verification").commands; + return Array.isArray(commands) ? commands.map(String) : []; +} + +/** Blank rows are dropped; the engine compares trimmed commands. */ +export const normalizeChecks = (rows: string[]) => + rows.map((row) => row.trim()).filter(Boolean); + +/** The error for one field's draft, or "" when the engine would accept it. */ +export function fieldError(field: AgentField, raw: string | boolean): string { + if (field.kind === "bool") return ""; + if (field.kind === "choice") + return field.options.some((o) => o.value === raw) + ? "" + : `Choose one of ${field.options.map((o) => PROFILE_LABELS[o.value] ?? o.value).join(", ")}.`; + const text = String(raw).trim(); + const value = Number(text); + const whole = field.kind === "int"; + const span = + field.max === Number.MAX_SAFE_INTEGER + ? `of at least ${formatNumber(field.min)}` + : `from ${formatNumber(field.min)} to ${formatNumber(field.max)}`; + const expected = `Enter a ${whole ? "whole number" : "number"} ${span}${field.unit ? ` ${field.unit}` : ""}.`; + if (!text || !Number.isFinite(value)) return expected; + if (whole && !Number.isInteger(value)) return expected; + if (value < field.min || value > field.max) return expected; + return ""; +} + +/** Errors for the check commands, by row, plus one for the list. */ +export function checkErrors(rows: string[]): { + rows: string[]; + list: string; +} { + const encoder = new TextEncoder(); + return { + rows: rows.map((row) => + row.includes("\0") + ? "A command cannot contain a NUL character." + : encoder.encode(row).length > MAX_CHECK_BYTES + ? `A command can be at most ${formatNumber(MAX_CHECK_BYTES)} bytes.` + : "", + ), + list: + normalizeChecks(rows).length > MAX_CHECKS + ? `Keep at most ${MAX_CHECKS} checks.` + : "", + }; +} + +export function parsed(field: AgentField, raw: string | boolean) { + if (field.kind === "bool") return raw === true; + if (field.kind === "choice") return String(raw); + return Number(String(raw).trim()); +} + +const sameList = (a: string[], b: string[]) => + a.length === b.length && a.every((value, i) => value === b[i]); + +/** Only the keys that differ from what is in use now. The engine deep-merges + * a partial save into the profile, and GET /api/config includes a trusted + * project's own `agent` values, so unchanged keys are never written back: + * they would copy that project's choices into every project. */ +export function agentPatch( + cfg: Record, + draft: Draft, + checks: string[], +): Record> { + const patch: Record> = {}; + for (const field of AGENT_FIELDS) { + const next = parsed(field, draft[fieldId(field)]); + const before = current(cfg, field); + if (next === before) continue; + (patch[field.group] ??= {})[field.key] = next; + } + const commands = normalizeChecks(checks); + if (!sameList(commands, normalizeChecks(checksFrom(cfg)))) + patch.verification = { commands }; + return patch; +} + +/** The field a refused save names (`agent.max_steps must be …`). */ +export function fieldNamedIn(message: string): string | null { + for (const field of AGENT_FIELDS) + if (new RegExp(`\\b${fieldId(field).replace(".", "\\.")}\\b`).test(message)) + return fieldId(field); + return /\bverification\.commands\b/.test(message) + ? "verification.commands" + : null; +} + +/** The token cap a named profile puts on a larger budget, if any. */ +export function profileCapNote(draft: Draft): string { + const profile = String(draft["agent.autonomy_profile"]); + const cap = PROFILE_TOKEN_CAPS[profile]; + const budget = Number(String(draft["agent.max_task_tokens"]).trim()); + if (!cap || !Number.isFinite(budget) || budget <= cap) return ""; + const enough = Object.keys(PROFILE_LABELS).filter( + (p) => !(p in PROFILE_TOKEN_CAPS) || PROFILE_TOKEN_CAPS[p] >= budget, + ); + const names = enough.map((p) => PROFILE_LABELS[p]); + const choices = `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`; + return `With the ${PROFILE_LABELS[profile]} profile a task still stops at ${formatNumber(cap)} tokens. Choose ${choices} to use more.`; +} From 4f1cffbc3da0a4dacf79d7156ee9e289a478e8f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:35:12 +0000 Subject: [PATCH 06/28] Docs: the retry example matches the default of three retries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fn7NbipAkYG7cYZxHk8xRu --- docs/USER_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 78d26cc3..3a2a8dfb 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1155,7 +1155,7 @@ Subscription CLIs use their own sandboxes, not this one. sent again, up to three times (**Provider retries** in **Settings › Agent**, `agent.model_retries`), waiting longer each time or as long as the provider asks. The conversation shows it, for - example *Provider busy, retrying (2 of 5) in 4 s…*, and then *The provider + example *Provider busy, retrying (2 of 3) in 4 s…*, and then *The provider was busy; it answered after 2 retries.* A reply that was cut off is thrown away and replaced; tools only run after a complete reply, so nothing runs twice. Errors such as a wrong key or an unknown model are shown at once. If From 5cbcf417dd0095f20740115940ca8eb2f4b77ae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:38:28 +0000 Subject: [PATCH 07/28] Transcript: find in conversation (Ctrl+F) There was no way to find text in a conversation: the transcript draws only its latest 150 rows and older history is paged in, so even a browser find could not reach most of a long conversation. Ctrl+F (also "Find in conversation" in the command palette) opens a compact find bar at the top of the stage. It searches the whole conversation's prompts, answers and notes, ignoring case: the latest messages, and every saved history page, read one page at a time in the background while a query is open (the same 128-event pages "Older messages" reads). Answers are matched as they read on screen (Markdown markers left out), and the bar shows "3 of 12" or "No matches" in a polite live region. Enter / Shift+Enter and F3 / Shift+F3 step through matches, wrapping. A match outside what is drawn is brought into view: its saved page opens as "Older messages" would (keeping Newer messages working), the row window grows down to its row, and it scrolls to the middle of the view, smoothly unless reduced motion is asked for. Every drawn match is highlighted with the CSS Custom Highlight API and the current one distinctly, in theme token colours; where the webview lacks the API (older WebKitGTK) the match's row is outlined instead. Escape closes the bar and returns focus; another conversation, Review or Compare closes it. A code editor's own find and the terminal keep Ctrl+F. Matching lives in a pure module (lib/find.ts) and the drawn-text side in lib/findHighlight.ts. Each item's searchable text is worked out once (items are replaced, never changed), each saved page is matched once per query, and the latest messages are searched again at most every 300 ms while an answer streams. Matches are kept as per-row counts, so a one-letter query in a long conversation stays small; at most 2,000 drawn matches are highlighted at once. useConversation gains history.show (open a saved page directly), history.base and liveItems; TranscriptRows marks searchable rows with a display: contents wrapper and takes a row to keep drawn. The performance fixture can load a larger latest window, which the new e2e spec uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fn7NbipAkYG7cYZxHk8xRu --- docs/USER_GUIDE.md | 7 + ui/e2e/fakePerformance.ts | 8 +- ui/e2e/find.spec.ts | 206 +++++++++++ ui/src/App.tsx | 7 + ui/src/components/overlays.tsx | 1 + ui/src/components/shell/FindBar.test.tsx | 125 +++++++ ui/src/components/shell/FindBar.tsx | 163 ++++++++ ui/src/components/shell/Stage.tsx | 36 ++ ui/src/components/shell/TranscriptRows.tsx | 26 +- ui/src/components/shell/palette.test.ts | 1 + ui/src/components/shell/palette.ts | 7 + ui/src/find.css | 76 ++++ ui/src/hooks/useConversation.test.ts | 32 ++ ui/src/hooks/useConversation.ts | 63 ++-- ui/src/hooks/useFind.test.tsx | 222 +++++++++++ ui/src/hooks/useFind.ts | 409 +++++++++++++++++++++ ui/src/hooks/useShortcuts.find.test.ts | 83 +++++ ui/src/hooks/useShortcuts.ts | 4 + ui/src/lib/find.test.ts | 174 +++++++++ ui/src/lib/find.ts | 276 ++++++++++++++ ui/src/lib/findHighlight.test.ts | 143 +++++++ ui/src/lib/findHighlight.ts | 194 ++++++++++ 22 files changed, 2230 insertions(+), 33 deletions(-) create mode 100644 ui/e2e/find.spec.ts create mode 100644 ui/src/components/shell/FindBar.test.tsx create mode 100644 ui/src/components/shell/FindBar.tsx create mode 100644 ui/src/find.css create mode 100644 ui/src/hooks/useFind.test.tsx create mode 100644 ui/src/hooks/useFind.ts create mode 100644 ui/src/hooks/useShortcuts.find.test.ts create mode 100644 ui/src/lib/find.test.ts create mode 100644 ui/src/lib/find.ts create mode 100644 ui/src/lib/findHighlight.test.ts create mode 100644 ui/src/lib/findHighlight.ts diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3a2a8dfb..58a10076 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1100,6 +1100,13 @@ Subscription CLIs use their own sandboxes, not this one. starts a new conversation at a response. A very long page shows its latest 150 items first; **Show … earlier items** adds more without moving what you are reading. +- **Find in conversation** (`Ctrl+F`, or *Find in conversation* in the + `Ctrl+K` command palette) searches the whole conversation's prompts, + answers and notes, older messages included, ignoring case, and shows + *3 of 12*. `Enter` / `Shift+Enter` (or `F3` / `Shift+F3`) go to the next or + previous match, opening older messages and earlier items as needed; every + match on screen is highlighted. `Esc` closes it. In a file editor or the + terminal, `Ctrl+F` stays theirs. - **Interrupted tasks.** If ShadowCode exits during a task, the task is marked *interrupted*. **Continue task** writes a recovery request for you. Shell commands and file edits are never replayed automatically. diff --git a/ui/e2e/fakePerformance.ts b/ui/e2e/fakePerformance.ts index 3eb8d14e..4286927e 100644 --- a/ui/e2e/fakePerformance.ts +++ b/ui/e2e/fakePerformance.ts @@ -8,6 +8,8 @@ export function installFakePerformance(options: { initialReplyChars?: number; intervalMs?: number; initialReplyText?: string; + /** Events in the conversation's latest window (history pages stay 128). */ + windowEvents?: number; }) { const scope = window as any; const inner = scope.__SHADOW_TEST_TRANSPORT__; @@ -42,9 +44,9 @@ export function installFakePerformance(options: { let timer: ReturnType | undefined; let job: any = null; let fragments = 0; - const page = (through: number) => { + const page = (through: number, size = pageSize) => { const events = saved.slice( - Math.max(0, Math.min(count, through) - pageSize), + Math.max(0, Math.min(count, through) - size), Math.min(count, through), ); return { @@ -98,7 +100,7 @@ export function installFakePerformance(options: { path === `/api/sessions/${session.id}/activate` ) { const result = await inner.request(fullPath, method, body); - const latest = page(count); + const latest = page(count, options.windowEvents ?? pageSize); return record(fullPath, { ...result, events: latest.events, diff --git a/ui/e2e/find.spec.ts b/ui/e2e/find.spec.ts new file mode 100644 index 00000000..e773840d --- /dev/null +++ b/ui/e2e/find.spec.ts @@ -0,0 +1,206 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; +import { installFakePerformance } from "./fakePerformance"; + +// Find in conversation (Ctrl+F) over a 1,000-message conversation: the +// latest 400 events load with the conversation (the transcript draws its +// latest 150 rows of them) and the rest are five saved history pages. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend); + await page.addInitScript(installFakePerformance, { + messages: 1000, + windowEvents: 400, + }); + await page.goto("/"); + await page.locator('.task-link[data-session-id="s1"]').click(); + await expect(rows(page).last()).toContainText("Saved message 01000."); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +const rows = (page: Page) => + page.locator(".msg-user .bubble, .msg-agent .markdown"); +const message = (page: Page, n: number) => + rows(page).filter({ + hasText: `Saved message ${String(n).padStart(5, "0")}.`, + }); +const findBox = (page: Page) => + page.getByRole("textbox", { name: "Find in conversation" }); +const count = (page: Page) => page.locator(".find-bar [role=status]"); +/** The text the current-match highlight covers ("" without one). */ +const currentHighlight = (page: Page) => + page.evaluate(() => { + const highlight = CSS.highlights.get("shadow-find-current"); + return highlight ? [...highlight].map(String).join("|") : ""; + }); + +test("Ctrl+F finds text in rows not drawn yet and in saved pages", async ({ + page, +}) => { + const composer = page.getByRole("textbox", { name: "Message ShadowCode" }); + await composer.focus(); + await page.keyboard.press("Control+f"); + await expect(findBox(page)).toBeFocused(); + + await findBox(page).fill("message 00990"); + await expect(count(page)).toHaveText("1 of 1"); + await expect(message(page, 990)).toBeInViewport(); + await expect.poll(() => currentHighlight(page)).toBe("message 00990"); + + // In the loaded conversation but outside the 150 rows drawn. + await expect(message(page, 700)).toHaveCount(0); + await findBox(page).fill("MESSAGE 00700"); + await expect(count(page)).toHaveText("1 of 1"); + await expect(message(page, 700)).toBeInViewport(); + await expect.poll(() => currentHighlight(page)).toBe("message 00700"); + + // Answers match as they read (no Markdown markers) and every drawn match + // is highlighted. A new query starts at the latest match where you are + // reading; stepping wraps into the oldest saved page and back. + await findBox(page).fill("with emphasis, code"); + await expect(count(page)).toHaveText("500 of 500"); + await expect + .poll(() => + page.evaluate(() => CSS.highlights.get("shadow-find")?.size || 0), + ) + .toBeGreaterThan(30); + await findBox(page).press("Enter"); + await expect(count(page)).toHaveText("1 of 500"); + await expect(page.getByText("Browsing saved history.")).toBeVisible(); + await expect(message(page, 2)).toBeInViewport(); + await findBox(page).press("Shift+Enter"); + await expect(count(page)).toHaveText("500 of 500"); + await expect(page.getByText("Browsing saved history.")).toHaveCount(0); + await expect(message(page, 1000)).toBeInViewport(); + + // Only in a saved page: it opens, like Older messages would. + await findBox(page).fill("message 00042"); + await expect(count(page)).toHaveText("1 of 1"); + await expect(page.getByText("Browsing saved history.")).toBeVisible(); + await expect(message(page, 42)).toBeInViewport(); + await expect.poll(() => currentHighlight(page)).toBe("message 00042"); + + // Nothing on this page: the latest match, back in the latest messages. + await findBox(page).fill("saved message 0099"); + await expect(count(page)).toHaveText("10 of 10"); + await expect(message(page, 999)).toBeInViewport(); + await findBox(page).press("Enter"); + await expect(count(page)).toHaveText("1 of 10"); + await expect(message(page, 990)).toBeInViewport(); + await page.keyboard.press("Shift+F3"); + await expect(count(page)).toHaveText("10 of 10"); + await page.keyboard.press("Shift+F3"); + await expect(count(page)).toHaveText("9 of 10"); + await page.keyboard.press("F3"); + await expect(count(page)).toHaveText("10 of 10"); + await page.getByRole("button", { name: "Previous match" }).click(); + await expect(count(page)).toHaveText("9 of 10"); + await expect(message(page, 998)).toBeInViewport(); + + await findBox(page).fill("nothing like this"); + await expect(count(page)).toHaveText("No matches"); + await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled(); + + // Escape closes find and puts focus back in the composer. + await findBox(page).press("Escape"); + await expect(page.locator(".find-bar")).toHaveCount(0); + await expect(composer).toBeFocused(); + expect(await currentHighlight(page)).toBe(""); +}); + +test("the palette and Ctrl+F open find; another conversation closes it", async ({ + page, +}) => { + await page.keyboard.press("Control+k"); + await page.getByLabel("Search commands").fill("Find in"); + await page.keyboard.press("Enter"); + await expect(findBox(page)).toBeFocused(); + await findBox(page).fill("message 00999"); + await expect(count(page)).toHaveText("1 of 1"); + + // Ctrl+F again (from elsewhere) selects the query to type over. + await page.getByRole("textbox", { name: "Message ShadowCode" }).focus(); + await page.keyboard.press("Control+f"); + await expect(findBox(page)).toBeFocused(); + expect( + await findBox(page).evaluate((input: HTMLInputElement) => [ + input.selectionStart, + input.selectionEnd, + ]), + ).toEqual([0, "message 00999".length]); + + await page.keyboard.press("Control+n"); + await expect(page.locator(".find-bar")).toHaveCount(0); + await page.keyboard.press("Control+f"); + await expect(findBox(page)).toHaveValue(""); +}); + +test("find reads well in both themes and passes axe", async ({ page }) => { + await page.keyboard.press("Control+f"); + await findBox(page).fill("Saved message 0099"); + await expect(count(page)).toHaveText("10 of 10"); + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .include(".find-bar") + .analyze(); + expect(results.violations).toEqual([]); + await page.locator(".stage").screenshot({ + path: `test-results/find-${theme}.png`, + }); + } +}); + +test("scrolling to a match is instant when reduced motion is asked for", async ({ + page, +}) => { + await page.evaluate(() => { + const scope = window as unknown as { scrolls: string[] }; + scope.scrolls = []; + const original = Element.prototype.scrollTo; + Element.prototype.scrollTo = function (this: Element, ...args: unknown[]) { + const options = args[0] as ScrollToOptions | undefined; + if (options?.behavior) scope.scrolls.push(options.behavior); + return (original as (...a: unknown[]) => void).apply(this, args); + }; + }); + const scrolls = () => + page.evaluate(() => (window as unknown as { scrolls: string[] }).scrolls); + await page.keyboard.press("Control+f"); + await findBox(page).fill("message 00900"); + await expect(message(page, 900)).toBeInViewport(); + expect(await scrolls()).toContain("smooth"); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.evaluate( + () => ((window as unknown as { scrolls: string[] }).scrolls = []), + ); + await findBox(page).fill("message 00700"); + await expect(message(page, 700)).toBeInViewport(); + expect(await scrolls()).toContain("auto"); + expect(await scrolls()).not.toContain("smooth"); +}); + +test("without the highlight API the match's row is marked", async ({ + page, +}) => { + await page.evaluate(() => { + delete (window as unknown as { Highlight?: unknown }).Highlight; + }); + await page.keyboard.press("Control+f"); + await findBox(page).fill("message 00800"); + await expect(count(page)).toHaveText("1 of 1"); + const marked = page.locator('[data-find-current="row"]'); + await expect(marked).toHaveCount(1); + await expect(marked).toContainText("Saved message 00800."); + await expect(message(page, 800)).toBeInViewport(); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1cd28175..166eca0b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -93,6 +93,8 @@ export default function App() { const [panel, setPanel] = useState(null); const [diffPath, setDiffPath] = useState(""); const [overlay, setOverlay] = useState(""); + /** Find in conversation: 0 while closed, bumped by each Ctrl+F. */ + const [find, setFind] = useState(0); const [submitting, setSubmitting] = useState(false); const allowanceReturn = useRef(false); const promptRef = useRef(null); @@ -457,6 +459,7 @@ export default function App() { inWorktree: Boolean(worktree.task), }); + const closeFind = useStableCallback(() => setFind(0)); const shortcuts: Record void> = { "close-overlay": () => setOverlay(""), "close-trust": () => nav.setTrust(null), @@ -474,6 +477,7 @@ export default function App() { stop: () => void controls.stop(), export: () => exportSession(), help: () => setOverlay("help"), + find: () => setFind((n) => n + 1), "previous-conversation": conversations.previous, "next-conversation": conversations.next, "recent-conversation": conversations.recent, @@ -646,6 +650,7 @@ export default function App() { .then(ws.reloadConfig) .catch((e) => toast(String(e), "err")), help: () => setOverlay("help"), + find: () => setFind((n) => n + 1), }); return ( @@ -722,6 +727,8 @@ export default function App() { setTryOn(null); if (pending) void actions.tryOnModel(id, pending); }} + find={find} + onCloseFind={closeFind} selectedTarget={selectedTarget} queuedJobs={queuedJobs} queuedTaskIds={queuedTaskIds} diff --git a/ui/src/components/overlays.tsx b/ui/src/components/overlays.tsx index 3eb403d7..74d08244 100644 --- a/ui/src/components/overlays.tsx +++ b/ui/src/components/overlays.tsx @@ -27,6 +27,7 @@ const SHORTCUT_GROUPS: { label: string; rows: [string, string][] }[] = [ ["Ctrl+N", "New task"], ["Alt+↑ / Alt+↓", "Previous / next conversation"], ["Ctrl+Tab", "Back to the last conversation"], + ["Ctrl+F", "Find in conversation"], ["Ctrl+L", "Focus composer"], ["Ctrl+M", "Choose a model"], ["Esc", "Close overlay or drawer"], diff --git a/ui/src/components/shell/FindBar.test.tsx b/ui/src/components/shell/FindBar.test.tsx new file mode 100644 index 00000000..589b5483 --- /dev/null +++ b/ui/src/components/shell/FindBar.test.tsx @@ -0,0 +1,125 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { FindBar } from "./FindBar"; + +afterEach(cleanup); + +function bar(over: Partial[0]> = {}) { + const props = { + query: "bug", + onQuery: vi.fn(), + label: "3 of 12", + total: 12, + searching: false, + error: "", + focusRequest: 1, + onNext: vi.fn(), + onPrevious: vi.fn(), + onClose: vi.fn(), + ...over, + }; + return { props, view: render() }; +} + +const input = () => + screen.getByRole("textbox", { name: "Find in conversation" }); + +it("is a labelled search with a polite count", () => { + const { props } = bar({ searching: true }); + expect(screen.getByRole("search", { name: "Find in conversation" })); + expect(input()).toHaveProperty("value", "bug"); + const status = screen.getByRole("status"); + expect(status.getAttribute("aria-live")).toBe("polite"); + expect(status.textContent).toBe("3 of 12, searching older messages"); + fireEvent.change(input(), { target: { value: "bugs" } }); + expect(props.onQuery).toHaveBeenCalledWith("bugs"); +}); + +it("steps with Enter, Shift+Enter, F3 and the buttons; Escape closes", () => { + const { props } = bar(); + fireEvent.keyDown(input(), { key: "Enter" }); + expect(props.onNext).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input(), { key: "Enter", shiftKey: true }); + expect(props.onPrevious).toHaveBeenCalledTimes(1); + // F3 works wherever focus is, but not inside a terminal or a dialog. + fireEvent.keyDown(document.body, { key: "F3" }); + expect(props.onNext).toHaveBeenCalledTimes(2); + fireEvent.keyDown(document.body, { key: "F3", shiftKey: true }); + expect(props.onPrevious).toHaveBeenCalledTimes(2); + const terminal = document.createElement("div"); + terminal.dataset.terminal = ""; + document.body.append(terminal); + fireEvent.keyDown(terminal, { key: "F3" }); + expect(props.onNext).toHaveBeenCalledTimes(2); + terminal.remove(); + fireEvent.click(screen.getByRole("button", { name: "Next match" })); + fireEvent.click(screen.getByRole("button", { name: "Previous match" })); + expect(props.onNext).toHaveBeenCalledTimes(3); + expect(props.onPrevious).toHaveBeenCalledTimes(3); + // Escape is handled here, so the window's shortcuts leave it alone. + const escape = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + input().dispatchEvent(escape); + expect(escape.defaultPrevented).toBe(true); + expect(props.onClose).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Close find" })); + expect(props.onClose).toHaveBeenCalledTimes(2); +}); + +it("cannot step without matches and says when older pages are missing", () => { + bar({ label: "No matches", total: 0, error: "Engine stopped" }); + expect(screen.getByRole("status").textContent).toBe("No matches"); + expect( + screen.getByRole("button", { name: "Next match" }).hasAttribute("disabled"), + ).toBe(true); + expect(screen.getByRole("alert").textContent).toBe( + "Older messages unavailable", + ); +}); + +function Host() { + const [open, setOpen] = useState(0); + return ( + <> + + {open > 0 && ( + undefined} + label="" + total={0} + searching={false} + error="" + focusRequest={open} + onNext={() => undefined} + onPrevious={() => undefined} + onClose={() => setOpen(0)} + /> + )} + + ); +} + +it("takes focus, selects the query on each request and gives focus back", () => { + render(); + const opener = screen.getByRole("button", { name: "Open" }); + opener.focus(); + fireEvent.click(opener); + expect(document.activeElement).toBe(input()); + const field = input() as HTMLInputElement; + field.setSelectionRange(3, 3); + // Ctrl+F again (from anywhere) selects the query to type over. + opener.focus(); + fireEvent.click(opener); + expect(document.activeElement).toBe(field); + expect([field.selectionStart, field.selectionEnd]).toEqual([0, 3]); + fireEvent.keyDown(field, { key: "Escape" }); + expect(screen.queryByRole("search")).toBeNull(); + expect(document.activeElement).toBe(opener); +}); diff --git a/ui/src/components/shell/FindBar.tsx b/ui/src/components/shell/FindBar.tsx new file mode 100644 index 00000000..27a7198a --- /dev/null +++ b/ui/src/components/shell/FindBar.tsx @@ -0,0 +1,163 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; +import { ChevronDown, ChevronUp, LoaderCircle, Search, X } from "lucide-react"; +import "../../find.css"; + +/** Keys inside a terminal, a dialog or a code editor (its own F3) are not + * find's. */ +const OWNED = '[data-terminal], [aria-modal="true"], .cm-editor'; + +/** The Find in conversation bar (Ctrl+F): the query, "3 of 12", previous, + * next and close. Enter / Shift+Enter and F3 / Shift+F3 step through the + * matches; Escape closes and puts focus back where it was. */ +export function FindBar({ + query, + onQuery, + label, + total, + searching, + error, + focusRequest, + onNext, + onPrevious, + onClose, +}: { + query: string; + onQuery: (query: string) => void; + /** "3 of 12", "No matches"; empty before a query. */ + label: string; + total: number; + /** Saved pages are still being read. */ + searching: boolean; + /** Saved pages could not be read. */ + error: string; + /** Changes on each Ctrl+F: focus and select the query again. */ + focusRequest: number; + onNext: () => void; + onPrevious: () => void; + onClose: () => void; +}) { + const bar = useRef(null); + const input = useRef(null); + // Where focus was when find opened (read after a closing command palette + // has put it back); it returns there when find closes from inside. + const before = useRef(null); + useEffect(() => { + before.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + }, []); + useLayoutEffect(() => { + const container = bar.current; + return () => { + const previous = before.current; + if (previous?.isConnected && container?.contains(document.activeElement)) + previous.focus(); + }; + }, []); + useEffect(() => { + input.current?.focus(); + input.current?.select(); + }, [focusRequest]); + const steps = useRef({ onNext, onPrevious }); + steps.current = { onNext, onPrevious }; + // F3 steps through matches wherever focus is, while the bar is open. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "F3" || e.defaultPrevented || e.ctrlKey || e.altKey) return; + const target = e.target instanceof Element ? e.target : null; + if (target?.closest(OWNED)) return; + e.preventDefault(); + if (e.shiftKey) steps.current.onPrevious(); + else steps.current.onNext(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + return ( +
{ + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }} + > +
+ ); +} diff --git a/ui/src/components/shell/Stage.tsx b/ui/src/components/shell/Stage.tsx index 1627d338..04bee42a 100644 --- a/ui/src/components/shell/Stage.tsx +++ b/ui/src/components/shell/Stage.tsx @@ -13,6 +13,7 @@ import type { DrawerTab } from "../Drawer"; import type { AdvancedTab, SettingsSection } from "../Settings"; import { ChatView } from "./ChatView"; import { ComposerDock, composerAccess } from "./ComposerDock"; +import { FindBar } from "./FindBar"; import { StageBanners } from "./StageBanners"; import { StatusBar } from "./StatusBar"; import { TranscriptRows, type RowActions } from "./TranscriptRows"; @@ -25,6 +26,7 @@ import type { ComposerExtras } from "../../hooks/useComposerExtras"; import type { useAllowance } from "../../hooks/useCatalog"; import type { useCompare } from "../../hooks/useCompare"; import type { useConversation } from "../../hooks/useConversation"; +import { useFind } from "../../hooks/useFind"; import type { useJobControls } from "../../hooks/useJobControls"; import type { useNavigation } from "../../hooks/useNavigation"; import type { useStickyScroll } from "../../hooks/useStickyScroll"; @@ -109,6 +111,8 @@ export function Stage({ worktreeBar, tryOn, onTryOnModel, + find: findRequest = 0, + onCloseFind = () => undefined, }: { conversation: ReturnType; compare: ReturnType; @@ -178,6 +182,10 @@ export function Stage({ /** "Try on…" is choosing a model to continue a stopped task. */ tryOn?: TryOn | null; onTryOnModel?: (targetId: string) => void; + /** Find in conversation: 0 while closed; each Ctrl+F changes it, which + * focuses the query again. */ + find?: number; + onCloseFind?: () => void; }) { const { transcript, job, busy, connection, history } = conversation; const { switching, sessionId, modelChoice, runningChoice } = nav; @@ -189,6 +197,19 @@ export function Stage({ pickerOpen, ); const view = compare.view; + const chatHidden = view === "compare" || Boolean(reviewPanel); + const find = useFind({ + open: findRequest > 0, + hidden: chatHidden, + sessionId, + liveItems: conversation.liveItems, + liveTaskId: transcript.activeTaskId, + queuedTaskIds, + history, + streamRef: scroll.streamRef, + release: scroll.release, + onClose: onCloseFind, + }); const locked = busy || submitting || switching || Boolean(shutdown); const composerLocked = submitting || switching || Boolean(shutdown); const access = composerAccess(cfg, status?.permissions.level, selectedTarget); @@ -245,6 +266,20 @@ export function Stage({ ) : undefined; return (
+ {findRequest > 0 && !chatHidden && ( + + )} } diff --git a/ui/src/components/shell/TranscriptRows.tsx b/ui/src/components/shell/TranscriptRows.tsx index 8856d9c5..33a78b96 100644 --- a/ui/src/components/shell/TranscriptRows.tsx +++ b/ui/src/components/shell/TranscriptRows.tsx @@ -370,19 +370,30 @@ const TranscriptRow = memo(function TranscriptRow({ ); if (stranded) - return ( + node = (
{node}
); - if (timelineHere) - return ( + else if (timelineHere) + node = (
{node}
); + // Find in conversation locates prompts, answers and notes by their key. + // `display: contents` keeps the wrapper out of the layout. + if ( + !queued && + (item.kind === "user" || item.kind === "agent" || item.kind === "note") + ) + return ( +
+ {node} +
+ ); return <>{node}; }); @@ -401,6 +412,7 @@ export function TranscriptRows({ actions, scrollRef, resetKey, + reveal, }: { items: ChatItem[]; activity: Record; @@ -414,6 +426,8 @@ export function TranscriptRows({ scrollRef: RefObject; /** Changes when another conversation or history page opens. */ resetKey: string; + /** A row Find in conversation is showing: earlier rows render down to it. */ + reveal?: string; }) { const rows = useMemo( () => @@ -430,6 +444,12 @@ export function TranscriptRows({ el.scrollTop += el.scrollHeight - anchor.current; anchor.current = null; }, [shown, scrollRef]); + const revealAt = reveal ? rows.findIndex((row) => row.key === reveal) : -1; + if (revealAt >= 0 && revealAt < rows.length - shown) + setLimit({ + key: resetKey, + rows: Math.ceil((rows.length - revealAt) / ROW_WINDOW) * ROW_WINDOW, + }); const start = Math.max(0, rows.length - shown); // Second opinions about a task follow the task's last row. const lastOfTask = useMemo(() => { diff --git a/ui/src/components/shell/palette.test.ts b/ui/src/components/shell/palette.test.ts index dcb353f9..d4e70963 100644 --- a/ui/src/components/shell/palette.test.ts +++ b/ui/src/components/shell/palette.test.ts @@ -33,6 +33,7 @@ it("the command palette reaches every drawer panel and Settings page", () => { stop: vi.fn(), toggleTheme: vi.fn(), help: vi.fn(), + find: vi.fn(), }; const items = paletteItems(actions); items.forEach((item) => item.run()); diff --git a/ui/src/components/shell/palette.ts b/ui/src/components/shell/palette.ts index a3e1073d..34d491f2 100644 --- a/ui/src/components/shell/palette.ts +++ b/ui/src/components/shell/palette.ts @@ -14,6 +14,7 @@ export type PaletteActions = { stop: () => void; toggleTheme: () => void; help: () => void; + find: () => void; }; /** The command palette (Ctrl+K) entries, in display order. */ @@ -32,6 +33,12 @@ export function paletteItems(a: PaletteActions): PaletteItem[] { hint: "Ctrl+P", run: a.openProject, }, + { + id: "find", + label: "Find in conversation", + hint: "Ctrl+F", + run: a.find, + }, { id: "changes", label: "Review changes", run: () => a.panel("changes") }, { id: "git", diff --git a/ui/src/find.css b/ui/src/find.css new file mode 100644 index 00000000..84209487 --- /dev/null +++ b/ui/src/find.css @@ -0,0 +1,76 @@ +/* Find in conversation (Ctrl+F): the bar above the transcript and what it + highlights. Colours come from tokens.css, so both themes read. */ + +.find-bar { + display: flex; + align-items: center; + gap: 2px; + align-self: flex-end; + max-width: calc(100% - 32px); + margin: 8px 16px 0; + padding: 2px 4px 2px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + box-shadow: var(--shadow-1); + font-size: 12px; +} +.find-bar:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} +.find-icon { + flex: none; + color: var(--muted); +} +/* The bar shows focus (its border), so the field itself has no ring. */ +.find-bar .find-input, +.find-bar .find-input:focus-visible { + flex: 1 1 200px; + width: 200px; + min-width: 0; + padding: 5px 6px; + border: 0; + background: transparent; + color: var(--text); + outline: none; +} +.find-count { + flex: none; + min-width: 64px; + padding: 0 6px; + color: var(--text-2); + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} +.find-searching { + flex: none; + color: var(--muted); +} +.find-error { + flex: none; + padding: 0 4px; + color: var(--danger); + white-space: nowrap; +} + +/* Rows find can locate; the wrapper adds no box of its own. */ +.find-row { + display: contents; +} +/* No highlight API (older WebKitGTK): the current match's row is marked. */ +.find-row[data-find-current="row"] > * { + outline: 2px solid var(--warn); + outline-offset: 4px; + border-radius: var(--radius-sm); +} + +::highlight(shadow-find) { + background-color: color-mix(in srgb, var(--warn) 28%, transparent); + color: var(--text); +} +::highlight(shadow-find-current) { + background-color: var(--warn); + color: var(--canvas); +} diff --git a/ui/src/hooks/useConversation.test.ts b/ui/src/hooks/useConversation.test.ts index 48ff127a..414946fb 100644 --- a/ui/src/hooks/useConversation.test.ts +++ b/ui/src/hooks/useConversation.test.ts @@ -314,3 +314,35 @@ it("reattaches the same running job after a snapshot and can retain the viewed p "New stream", ]); }); +it("opens a saved page directly, keeping Newer messages and the live items", async () => { + vi.mocked(api.historyPage).mockResolvedValue({ + events: [event(40, "Oldest answer")], + first_cursor: 40, + event_cursor: 99, + has_older: false, + }); + const { result } = renderHook(() => useConversation(vi.fn())); + act(() => result.current.load(detail(), null)); + expect(result.current.history.base).toEqual({ + first_cursor: 200, + has_older: true, + }); + // Find in conversation opens the second saved page without the first. + await act(async () => { + await result.current.history.show(100, [0, 200]); + }); + expect(api.historyPage).toHaveBeenCalledTimes(1); + expect(api.historyPage).toHaveBeenCalledWith("session", 100); + expect(result.current.history.firstCursor).toBe(40); + expect(result.current.transcript.items[0].text).toBe("Oldest answer"); + expect(result.current.liveItems[0].text).toBe("Recent answer"); + vi.mocked(api.historyPage).mockResolvedValue(page); + await act(async () => { + await result.current.history.newer(); + }); + expect(api.historyPage).toHaveBeenLastCalledWith("session", 200); + await act(async () => { + await result.current.history.newer(); + }); + expect(result.current.history.viewing).toBe(false); +}); diff --git a/ui/src/hooks/useConversation.ts b/ui/src/hooks/useConversation.ts index 224c54b2..7a803b65 100644 --- a/ui/src/hooks/useConversation.ts +++ b/ui/src/hooks/useConversation.ts @@ -242,6 +242,29 @@ export function useConversation(onComplete: (done?: Job) => void) { }; }, [job?.id, job?.status, streamEpoch]); + /** Shows the saved page that ends before `before`; `newer` is the stack + * "Newer messages" walks back to the latest messages. */ + const showPage = useCallback(async (before: number, newer: number[]) => { + if (!session.current) return; + const request = ++historyRequest.current; + const selected = session.current; + historyPending.current = true; + setHistoryLoading(true); + setHistoryError(""); + try { + const page = await api.historyPage(selected, before); + if (request !== historyRequest.current || selected !== session.current) + return; + setHistory({ page, state: replay(page.events), before, newer }); + } catch (error) { + if (request === historyRequest.current) setHistoryError(String(error)); + } finally { + if (request === historyRequest.current) { + historyPending.current = false; + setHistoryLoading(false); + } + } + }, []); const pageHistory = useCallback( async (direction: "older" | "newer") => { if (historyPending.current || !session.current) return; @@ -253,34 +276,14 @@ export function useConversation(onComplete: (done?: Job) => void) { if (direction === "newer") clearHistory(); return; } - const request = ++historyRequest.current; - const selected = session.current; - historyPending.current = true; - setHistoryLoading(true); - setHistoryError(""); - try { - const page = await api.historyPage(selected, before); - if (request !== historyRequest.current || selected !== session.current) - return; - setHistory({ - page, - state: replay(page.events), - before, - newer: - direction === "older" - ? [...(history?.newer || []), history?.before || 0] - : (history?.newer || []).slice(0, -1), - }); - } catch (error) { - if (request === historyRequest.current) setHistoryError(String(error)); - } finally { - if (request === historyRequest.current) { - historyPending.current = false; - setHistoryLoading(false); - } - } + await showPage( + before, + direction === "older" + ? [...(history?.newer || []), history?.before || 0] + : (history?.newer || []).slice(0, -1), + ); }, - [history, historyBase, clearHistory], + [history, historyBase, clearHistory, showPage], ); const setTranscript = useCallback( (update: SetStateAction) => { @@ -315,7 +318,13 @@ export function useConversation(onComplete: (done?: Job) => void) { older: () => pageHistory("older"), newer: () => pageHistory("newer"), latest: clearHistory, + /** The latest messages' window: where saved pages begin. */ + base: historyBase, + /** Opens a saved page directly (Find in conversation). */ + show: showPage, }, + /** The latest messages, also while a saved page is shown. */ + liveItems: liveTranscript.items, job, setJob, connection, diff --git a/ui/src/hooks/useFind.test.tsx b/ui/src/hooks/useFind.test.tsx new file mode 100644 index 00000000..08185b01 --- /dev/null +++ b/ui/src/hooks/useFind.test.tsx @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, +} from "@testing-library/react"; +import { useRef } from "react"; +import { api, type EventRow } from "../api"; +import type { ChatItem } from "../components/cards"; +import { + ROW_WINDOW, + TranscriptRows, + type RowActions, +} from "../components/shell/TranscriptRows"; +import { useFind, type FindHistory } from "./useFind"; + +vi.mock("../api", () => ({ api: { historyPage: vi.fn() } })); +vi.mock("../components/Markdown", () => ({ + Markdown: ({ children }: { children: string }) => ( +
{children}
+ ), +})); + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +const actions = new Proxy({} as RowActions, { get: () => vi.fn() }); +const notes = (count: number, word = "Note"): ChatItem[] => + Array.from({ length: count }, (_, i) => ({ + kind: "note", + text: `${word} ${i + 1}`, + key: `e:${word}:${i + 1}`, + })); +const latest = (): FindHistory => ({ + viewing: false, + firstCursor: 0, + base: null, + latest: vi.fn(), + show: vi.fn(async () => undefined), +}); + +function Harness({ + items, + history = latest(), + queued = new Set(), + release = () => undefined, +}: { + items: ChatItem[]; + history?: FindHistory; + queued?: ReadonlySet; + release?: () => void; +}) { + const streamRef = useRef(null); + const find = useFind({ + open: true, + hidden: false, + sessionId: "s1", + liveItems: items, + queuedTaskIds: queued, + history, + streamRef, + release, + onClose: () => undefined, + }); + return ( + <> + find.setQuery(e.target.value)} + /> + {find.label} + + +
+ +
+ + ); +} + +async function type(text: string) { + fireEvent.change(screen.getByRole("textbox", { name: "Query" }), { + target: { value: text }, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); +} +const count = () => screen.getByRole("status", { name: "Count" }).textContent; +const step = async (name: "Next" | "Previous") => { + fireEvent.click(screen.getByRole("button", { name })); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); +}; + +it("finds rows the transcript has not drawn and draws the current one", async () => { + const release = vi.fn(); + render(); + expect(screen.queryByText("Note 12")).toBeNull(); + // "Note 12" and "Note 120" … "Note 129", whatever the case. + await type("NOTE 12"); + // It starts on the latest match, which is drawn. + expect(count()).toBe("11 of 11"); + await step("Next"); + expect(count()).toBe("1 of 11"); + const row = screen.getByText("Note 12").closest("[data-find-row]")!; + // No highlight API here: the match's row is marked instead. + expect(row.getAttribute("data-find-current")).toBe("row"); + expect(release).toHaveBeenCalled(); + await step("Previous"); + expect(count()).toBe("11 of 11"); + expect(row.hasAttribute("data-find-current")).toBe(false); + await type("no such note"); + expect(count()).toBe("No matches"); + await type(""); + expect(count()).toBe(""); +}); + +it("leaves out a queued follow-up, which the transcript does not show", async () => { + const items: ChatItem[] = [ + ...notes(2), + { kind: "user", text: "Queued question", taskId: "q", key: "u" }, + ]; + render(); + await type("queued"); + expect(count()).toBe("No matches"); +}); + +const saved = (from: number, to: number): EventRow[] => + Array.from({ length: to - from + 1 }, (_, i) => ({ + id: from + i, + ts: 1, + type: "user.message", + task_id: `t${from + i}`, + payload: { text: `Saved ${from + i}` }, + })); + +it("reads saved pages one by one and opens the page a match is on", async () => { + vi.mocked(api.historyPage).mockImplementation(async (_, before) => + before === 101 + ? { + events: saved(51, 100), + first_cursor: 51, + event_cursor: 100, + has_older: true, + } + : { + events: saved(1, 50), + first_cursor: 1, + event_cursor: 50, + has_older: false, + }, + ); + const history = { + ...latest(), + base: { first_cursor: 101, has_older: true }, + }; + render(); + await type("saved 7"); + expect(api.historyPage).toHaveBeenNthCalledWith(1, "s1", 101); + expect(api.historyPage).toHaveBeenNthCalledWith(2, "s1", 51); + // "Saved 7" (the oldest page) and "Saved 70" … "Saved 79". Nothing in + // the latest messages, so it starts on the latest match and opens its + // page, as Older messages would. + expect(count()).toBe("11 of 11"); + expect(history.show).toHaveBeenLastCalledWith(101, [0]); + await step("Next"); + expect(count()).toBe("1 of 11"); + expect(history.show).toHaveBeenLastCalledWith(51, [0, 101]); + // Each page is read once per conversation, whatever the query. + await type("saved 4"); + expect(count()).toBe("11 of 11"); + expect(api.historyPage).toHaveBeenCalledTimes(2); +}); + +it("closes when Review or Compare replaces the conversation, or another opens", () => { + const onClose = vi.fn(); + const props = { + open: true, + hidden: false, + sessionId: "s1", + liveItems: notes(2), + queuedTaskIds: new Set(), + history: latest(), + streamRef: { current: null }, + release: () => undefined, + onClose, + }; + const { rerender } = renderHook((p: typeof props) => useFind(p), { + initialProps: props, + }); + expect(onClose).not.toHaveBeenCalled(); + rerender({ ...props, hidden: true }); + expect(onClose).toHaveBeenCalledTimes(1); + rerender({ ...props, sessionId: "s2" }); + expect(onClose).toHaveBeenCalledTimes(2); +}); diff --git a/ui/src/hooks/useFind.ts b/ui/src/hooks/useFind.ts new file mode 100644 index 00000000..ef80a82a --- /dev/null +++ b/ui/src/hooks/useFind.ts @@ -0,0 +1,409 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; +import { api, type HistoryPage } from "../api"; +import type { ChatItem } from "../components/cards"; +import { replay } from "../lib/transcript"; +import { readableError } from "../lib/transport"; +import { + LIVE_PAGE, + combineHits, + findDocs, + findLabel, + findNeedle, + firstMatch, + hitsIn, + matchAt, + matchIndex, + stepIndex, + type FindDoc, + type FindMatch, +} from "../lib/find"; +import { + FIND_ROW_ATTR, + clearMatches, + markRow, + paintMatches, + rangesIn, + revealTarget, + scrollToTarget, + smoothScrolling, +} from "../lib/findHighlight"; + +/** Typing settles this long before the conversation is searched. */ +const TYPING_MS = 120; +/** A streamed answer is searched again at most this often. */ +const STREAM_MS = 300; +/** Drawn rows are highlighted again this long after they change. */ +const PAINT_MS = 80; +/** Highlights drawn at once; the current match always is. */ +const MAX_PAINTED = 2000; +/** Late layout (an answer's Markdown parsed off the main thread) can move a + * match just shown; it is brought back into view for this long, once a + * smooth scroll has had time to arrive. */ +const SETTLE_MS = 1000; +const SMOOTH_MS = 450; + +/** A saved history page as find reads it, and how to open it: `before` and + * `newer` as useConversation's paging records them. */ +type SavedPage = { + first: number; + before: number; + newer: number[]; + docs: FindDoc[]; +}; +type Scan = { + key: string; + pages: SavedPage[]; + next: { before: number; newer: number[] } | null; + error: string; +}; + +/** The conversation's saved-history paging (from useConversation). */ +export type FindHistory = { + viewing: boolean; + firstCursor: number; + base: { first_cursor: number; has_older: boolean } | null; + latest: () => void; + show: (before: number, newer: number[]) => Promise; +}; + +const NO_PAGES: SavedPage[] = []; + +const scanFor = (key: string, base: FindHistory["base"]): Scan => ({ + key, + pages: [], + next: + base?.has_older && base.first_cursor > 0 + ? { before: base.first_cursor, newer: [0] } + : null, + error: "", +}); + +/** `value` while `active`, updated at most every `ms` (a streamed answer + * changes many times a second); the latest value at once when it starts. */ +function useThrottled(value: T, ms: number, active: boolean): T { + const [held, setHeld] = useState<{ value: T } | null>(null); + const last = useRef(0); + useEffect(() => { + if (!active) { + setHeld(null); + return; + } + const apply = () => { + last.current = Date.now(); + setHeld({ value }); + }; + const wait = last.current + ms - Date.now(); + if (wait <= 0) { + apply(); + return; + } + const timer = setTimeout(apply, wait); + return () => clearTimeout(timer); + }, [value, ms, active]); + return active && held ? held.value : value; +} + +/** Find in conversation: matches across the whole conversation (the latest + * messages and every saved page, read page by page in the background), the + * current match, and bringing it into view: opening its page, rendering + * its row and scrolling to it with every drawn match highlighted. */ +export function useFind({ + open: requested, + hidden, + sessionId, + liveItems, + liveTaskId, + queuedTaskIds, + history, + streamRef, + release, + onClose, +}: { + open: boolean; + /** Review or Compare shows in the conversation's place. */ + hidden: boolean; + sessionId: string; + /** The latest messages, also while a saved page is shown. */ + liveItems: ChatItem[]; + liveTaskId?: string; + /** Queued follow-ups show in the queue, not the transcript. */ + queuedTaskIds: ReadonlySet; + history: FindHistory; + streamRef: RefObject; + /** Stop following new content (the reader is looking at a match). */ + release: () => void; + onClose: () => void; +}) { + const open = requested && !hidden; + // Review and Compare take the conversation's place; find closes with it. + useEffect(() => { + if (requested && hidden) onClose(); + }, [requested, hidden, onClose]); + const [query, setQuery] = useState(""); + const [needle, setNeedle] = useState(""); + useEffect(() => { + const next = open ? findNeedle(query) : ""; + const timer = setTimeout(() => setNeedle(next), next ? TYPING_MS : 0); + return () => clearTimeout(timer); + }, [query, open]); + const active = open && Boolean(needle); + const [current, setCurrent] = useState<{ + needle: string; + match: FindMatch; + } | null>(null); + + // Another conversation closes find and forgets the query. + const shownSession = useRef(sessionId); + useEffect(() => { + if (shownSession.current === sessionId) return; + shownSession.current = sessionId; + setQuery(""); + setNeedle(""); + setCurrent(null); + if (requested) onClose(); + }, [sessionId, requested, onClose]); + + // The latest messages, searched again at most every STREAM_MS. + const live = useThrottled(liveItems, STREAM_MS, active); + const skip = useCallback( + (item: ChatItem) => + item.kind === "user" && + Boolean(item.taskId) && + item.taskId !== liveTaskId && + queuedTaskIds.has(item.taskId!), + [liveTaskId, queuedTaskIds], + ); + const liveDocs = useMemo( + () => (active ? findDocs(live, skip) : []), + [active, live, skip], + ); + + // Saved pages, newest first, read one at a time while find has a query. + const base = history.base; + const scanKey = `${sessionId}:${base?.first_cursor ?? 0}:${base?.has_older ?? false}`; + const [scan, setScan] = useState(() => scanFor("", null)); + useEffect(() => { + if (!active || !sessionId) return; + if (scan.key !== scanKey) { + setScan(scanFor(scanKey, base)); + return; + } + const next = scan.next; + if (!next) return; + let cancelled = false; + api.historyPage(sessionId, next.before).then( + (page: HistoryPage) => { + if (cancelled) return; + const first = page.first_cursor; + setScan((current) => + current !== scan + ? current + : { + ...current, + pages: first + ? [ + ...current.pages, + { + first, + before: next.before, + newer: next.newer, + docs: findDocs(replay(page.events).items), + }, + ] + : current.pages, + next: + page.has_older && first > 0 && first < next.before + ? { before: first, newer: [...next.newer, next.before] } + : null, + }, + ); + }, + (error: unknown) => { + if (cancelled) return; + setScan((current) => + current !== scan + ? current + : { ...current, next: null, error: readableError(error) }, + ); + }, + ); + return () => { + cancelled = true; + }; + // `base` is part of scanKey. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, sessionId, scanKey, scan]); + const pages = scan.key === scanKey ? scan.pages : NO_PAGES; + const searching = active && scan.key === scanKey && Boolean(scan.next); + + // Each saved page is matched once per query. + const pageHits = useRef( + new WeakMap< + SavedPage, + { needle: string; hits: ReturnType } + >(), + ); + const results = useMemo(() => { + const saved = pages + .slice() + .reverse() + .map((page) => { + let cached = pageHits.current.get(page); + if (cached?.needle !== needle) { + cached = { needle, hits: hitsIn(page.docs, needle) }; + pageHits.current.set(page, cached); + } + return { page: page.first, hits: cached.hits }; + }); + return combineHits([ + ...saved, + { page: LIVE_PAGE, hits: hitsIn(liveDocs, needle) }, + ]); + }, [pages, liveDocs, needle]); + + const visible = history.viewing ? history.firstCursor : LIVE_PAGE; + const match = active && current?.needle === needle ? current.match : null; + const index = matchIndex(results, match); + /** A match asked for and not yet scrolled to (or still settling). */ + const pending = useRef<{ + match: FindMatch; + shown: number; + calm: number; + } | null>(null); + + const go = useCallback( + (next: FindMatch) => { + setCurrent({ needle, match: next }); + pending.current = { match: next, shown: 0, calm: 0 }; + if (next.page === LIVE_PAGE) { + if (history.viewing) history.latest(); + } else if (!(history.viewing && history.firstCursor === next.page)) { + const page = pages.find((saved) => saved.first === next.page); + if (page) void history.show(page.before, page.newer); + } + }, + [needle, history, pages], + ); + + // A new query starts on the latest match where the reader is (once one + // is found, and once the saved page being read has been searched); a + // match that went away (an answer rewritten) is replaced. + const visibleRead = + visible === LIVE_PAGE || + !searching || + pages.some((page) => page.first === visible); + useEffect(() => { + if (!active || index >= 0 || !results.total || !visibleRead) return; + const first = firstMatch(results, visible); + if (first) go(first); + }, [active, index, results, visible, visibleRead, go]); + + const step = useCallback( + (by: 1 | -1) => { + const next = matchAt(results, stepIndex(index, results.total, by)); + if (next) go(next); + }, + [results, index, go], + ); + + // Highlights follow the drawn rows: every match in them, the current one + // distinct; the current row is marked where text cannot be highlighted. + const rowsOnPage = useMemo(() => { + const keys = new Set(); + for (const hit of results.hits) if (hit.page === visible) keys.add(hit.key); + return keys; + }, [results, visible]); + const paint = useRef(() => {}); + paint.current = () => { + const stream = streamRef.current; + if (!stream) return; + if (!active) { + clearMatches(stream); + return; + } + const want = match && match.page === visible ? match : null; + const all: Range[] = []; + let row: HTMLElement | null = null; + let range: Range | null = null; + for (const each of stream.querySelectorAll( + `[${FIND_ROW_ATTR}]`, + )) { + const key = each.getAttribute(FIND_ROW_ATTR) || ""; + if (!rowsOnPage.has(key)) continue; + const ranges = rangesIn(each, needle); + if (want?.key === key) { + row = each; + range = ranges[want.occurrence] || null; + } + for (const found of ranges) + if (found !== range && all.length < MAX_PAINTED) all.push(found); + } + const painted = paintMatches(all, range); + markRow(stream, row, painted && range ? "text" : "row"); + const asked = pending.current; + if (!asked || !row || !want || asked.match !== want) return; + const target = revealTarget(row, range); + // Scrolling away from the bottom stops the conversation following new + // output (as scrolling up by hand does). + const now = Date.now(); + if (!asked.shown) { + const smooth = smoothScrolling(); + if (scrollToTarget(stream, target, smooth)) release(); + asked.shown = now; + asked.calm = now + (smooth ? SMOOTH_MS : 0); + } else if (now - asked.shown > SETTLE_MS) pending.current = null; + else if (now >= asked.calm && scrollToTarget(stream, target, false)) + release(); + }; + useEffect(() => { + paint.current(); + }, [active, results, match, visible, needle]); + useEffect(() => { + const stream = streamRef.current; + if (!active || !stream) return; + let timer: ReturnType | undefined; + const observer = new MutationObserver(() => { + timer ??= setTimeout(() => { + timer = undefined; + paint.current(); + }, PAINT_MS); + }); + observer.observe(stream, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + observer.disconnect(); + clearTimeout(timer); + clearMatches(stream); + }; + }, [active, streamRef]); + + const total = results.total; + return { + query, + setQuery, + /** "3 of 12", "No matches", or nothing before a query. */ + label: !needle + ? "" + : !total && searching + ? "Searching…" + : findLabel(index, total), + total, + searching, + /** Saved pages could not be read; matches there are missing. */ + error: active && scan.key === scanKey ? scan.error : "", + next: () => step(1), + previous: () => step(-1), + /** The row to keep rendered on the page being read. */ + reveal: match && match.page === visible ? match.key : undefined, + }; +} diff --git a/ui/src/hooks/useShortcuts.find.test.ts b/ui/src/hooks/useShortcuts.find.test.ts new file mode 100644 index 00000000..3b3490b0 --- /dev/null +++ b/ui/src/hooks/useShortcuts.find.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, renderHook } from "@testing-library/react"; +import { + shortcutFor, + useShortcuts, + type ShortcutContext, +} from "./useShortcuts"; + +afterEach(cleanup); + +const closed: ShortcutContext = { + consent: false, + overlay: false, + trust: false, + picker: false, + panel: false, + onboarding: false, +}; +const press = ( + target: Element, + extra: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } = {}, + open = closed, +) => + shortcutFor( + { + key: "f", + ctrlKey: false, + metaKey: false, + shiftKey: false, + target, + ...extra, + }, + open, + ); + +it("Ctrl+F finds in the conversation, also from the composer", () => { + expect(press(document.body, { ctrlKey: true })).toBe("find"); + expect(press(document.body, { metaKey: true })).toBe("find"); + expect(press(document.createElement("textarea"), { ctrlKey: true })).toBe( + "find", + ); + expect(press(document.body)).toBeNull(); + expect(press(document.body, { ctrlKey: true, shiftKey: true })).toBeNull(); +}); + +it("leaves Ctrl+F to a terminal, a dialog and an open overlay", () => { + const terminal = document.createElement("div"); + terminal.dataset.terminal = "t"; + const input = document.createElement("textarea"); + terminal.append(input); + expect(press(input, { ctrlKey: true })).toBeNull(); + const dialog = document.createElement("div"); + dialog.setAttribute("aria-modal", "true"); + const field = document.createElement("input"); + dialog.append(field); + expect(press(field, { ctrlKey: true })).toBeNull(); + expect( + press(document.body, { ctrlKey: true }, { ...closed, overlay: true }), + ).toBeNull(); +}); + +it("a code editor's own find (which handles the key first) wins", () => { + const run = vi.fn(); + renderHook(() => useShortcuts(closed, run)); + const editor = document.createElement("div"); + editor.className = "cm-editor"; + document.body.append(editor); + editor.addEventListener("keydown", (e) => e.preventDefault(), { + once: true, + }); + const key = () => + new KeyboardEvent("keydown", { + key: "f", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + editor.dispatchEvent(key()); + expect(run).not.toHaveBeenCalled(); + document.body.dispatchEvent(key()); + expect(run).toHaveBeenCalledWith("find"); + editor.remove(); +}); diff --git a/ui/src/hooks/useShortcuts.ts b/ui/src/hooks/useShortcuts.ts index c023a0e0..8fbd7172 100644 --- a/ui/src/hooks/useShortcuts.ts +++ b/ui/src/hooks/useShortcuts.ts @@ -28,6 +28,7 @@ export type ShortcutAction = | "stop" | "export" | "help" + | "find" | "previous-conversation" | "next-conversation" | "recent-conversation"; @@ -83,6 +84,9 @@ export function shortcutFor( if (mod && key === "l") return "focus"; if (mod && key === ".") return "stop"; if (mod && e.shiftKey && key === "e") return "export"; + // Find in conversation. A code editor's own find (Ctrl+F) handles the key + // first, and a terminal keeps it (above). + if (mod && !e.shiftKey && !e.altKey && key === "f") return "find"; // Conversations: Alt+↑/↓ previous/next in the sidebar, Ctrl+Tab the one // opened before this one. if (e.altKey && !mod && key === "arrowup") return "previous-conversation"; diff --git a/ui/src/lib/find.test.ts b/ui/src/lib/find.test.ts new file mode 100644 index 00000000..ec466e1c --- /dev/null +++ b/ui/src/lib/find.test.ts @@ -0,0 +1,174 @@ +import { expect, it } from "vitest"; +import type { ChatItem } from "../components/cards"; +import { + LIVE_PAGE, + combineHits, + countIn, + findDocs, + findLabel, + findNeedle, + firstMatch, + foldCase, + hitsIn, + itemText, + markdownText, + matchAt, + matchIndex, + offsetsIn, + searchText, + stepIndex, +} from "./find"; + +it("folds case without moving any character", () => { + expect(foldCase("Hello WORLD")).toBe("hello world"); + // "İ" lower-cases to two characters; it stays as it is. + const text = "İstanbul Straße"; + expect(foldCase(text)).toHaveLength(text.length); + expect(foldCase(text)).toBe("İstanbul straße"); +}); + +it("matches queries trimmed, with spaces collapsed, ignoring case", () => { + expect(findNeedle(" Fix the\tBug ")).toBe("fix the bug"); + expect(findNeedle(" ")).toBe(""); +}); + +it("reads an answer's Markdown as it shows on screen", () => { + expect( + markdownText( + "## Plan\n\nRecorded answer with **emphasis**, `code`, and [a link](https://x.dev).", + ), + ).toBe("Plan\n\nRecorded answer with emphasis, code, and a link."); + expect(markdownText("- [x] done\n1. first\n> quoted _note_")).toBe( + "done\nfirst\nquoted note", + ); + // Code keeps its characters; snake_case and lone stars are not emphasis. + expect( + markdownText( + "```ts\nconst a = b ** 2; // **x**\n```\nsnake_case_name, 2 * 3", + ), + ).toBe("const a = b ** 2; // **x**\nsnake_case_name, 2 * 3"); + expect(markdownText("Run `` a `b` c `` now")).toBe("Run a `b` c now"); + expect(markdownText("| a | b |\n|---|:-:|\n| 1 | 2 |")).toBe( + " a b \n\n 1 2 ", + ); + expect(markdownText("![diagram](x.png) and \\*literal\\*")).toBe( + "[Image: diagram] and *literal*", + ); + expect(markdownText("~~old~~ __new__ ***both***")).toBe("old new both"); +}); + +it("searches prompts, answers and notes, not cards or tool steps", () => { + const items: ChatItem[] = [ + { kind: "user", text: "Fix the **bug**", key: "u" }, + { kind: "agent", text: "Fixed the **bug**", key: "a" }, + { kind: "note", text: "Using Codex", key: "n" }, + { kind: "tool", tool: "read_file", text: "bug.ts", key: "t" }, + { kind: "summary", taskId: "t1", text: "bug" }, + ]; + expect(items.map(itemText)).toEqual([ + "Fix the **bug**", + "Fixed the bug", + "Using Codex", + null, + null, + ]); + // Rows are keyed as TranscriptRows keys them; skipped rows are left out. + expect(findDocs(items, (item) => item.kind === "note")).toEqual([ + { key: "u", text: "fix the **bug**" }, + { key: "a", text: "fixed the bug" }, + ]); + expect(findDocs([{ kind: "note", text: "A\n\n B" }])).toEqual([ + { key: "i:0", text: "a b" }, + ]); +}); + +it("works out an item's text once", () => { + const item: ChatItem = { kind: "agent", text: "**Once**", key: "a" }; + const first = searchText(item); + expect(first).toBe("once"); + item.text = "changed in place"; + expect(searchText(item)).toBe(first); + expect(searchText({ ...item })).toBe("changed in place"); +}); + +it("counts matches without overlap, as browsers do", () => { + expect(countIn("aaaa", "aa")).toBe(2); + expect(countIn("abc", "")).toBe(0); + expect(offsetsIn("a b a b a", "a b")).toEqual([0, 4]); + expect( + hitsIn( + [ + { key: "x", text: "bug bug" }, + { key: "y", text: "nothing" }, + { key: "z", text: "a bug" }, + ], + "bug", + ), + ).toEqual([ + { key: "x", count: 2 }, + { key: "z", count: 1 }, + ]); +}); + +const results = () => + combineHits([ + // An older saved page, then the latest messages. + { + page: 40, + hits: [ + { key: "p1", count: 2 }, + { key: "p2", count: 1 }, + ], + }, + { page: LIVE_PAGE, hits: [{ key: "l1", count: 3 }] }, + ]); + +it("numbers matches oldest first across pages", () => { + const all = results(); + expect(all.total).toBe(6); + const listed = Array.from({ length: all.total }, (_, i) => matchAt(all, i)); + expect(listed).toEqual([ + { page: 40, key: "p1", occurrence: 0 }, + { page: 40, key: "p1", occurrence: 1 }, + { page: 40, key: "p2", occurrence: 0 }, + { page: 0, key: "l1", occurrence: 0 }, + { page: 0, key: "l1", occurrence: 1 }, + { page: 0, key: "l1", occurrence: 2 }, + ]); + listed.forEach((match, i) => expect(matchIndex(all, match)).toBe(i)); + expect(matchAt(all, 6)).toBeNull(); + expect(matchAt(all, -1)).toBeNull(); + // A match the list no longer has. + expect(matchIndex(all, { page: 0, key: "l1", occurrence: 3 })).toBe(-1); + expect(matchIndex(all, { page: 40, key: "gone", occurrence: 0 })).toBe(-1); + expect(matchIndex(all, null)).toBe(-1); +}); + +it("starts on the latest match of the page being read", () => { + const all = results(); + expect(firstMatch(all, LIVE_PAGE)).toEqual({ + page: 0, + key: "l1", + occurrence: 2, + }); + expect(firstMatch(all, 40)).toEqual({ page: 40, key: "p2", occurrence: 0 }); + // Nothing on this page: the latest in the conversation. + expect(firstMatch(all, 99)).toEqual({ page: 0, key: "l1", occurrence: 2 }); + expect(firstMatch(combineHits([]), 0)).toBeNull(); +}); + +it("steps through matches, wrapping at both ends", () => { + expect(stepIndex(0, 3, 1)).toBe(1); + expect(stepIndex(2, 3, 1)).toBe(0); + expect(stepIndex(0, 3, -1)).toBe(2); + expect(stepIndex(-1, 3, 1)).toBe(0); + expect(stepIndex(-1, 3, -1)).toBe(2); + expect(stepIndex(0, 0, 1)).toBe(-1); +}); + +it("says where you are in the matches", () => { + expect(findLabel(2, 12)).toBe("3 of 12"); + expect(findLabel(0, 0)).toBe("No matches"); + expect(findLabel(-1, 1)).toBe("1 match"); + expect(findLabel(1233, 10000)).toBe("1,234 of 10,000"); +}); diff --git a/ui/src/lib/find.ts b/ui/src/lib/find.ts new file mode 100644 index 00000000..a757a09b --- /dev/null +++ b/ui/src/lib/find.ts @@ -0,0 +1,276 @@ +import type { ChatItem } from "../components/cards"; + +/** Find in conversation: what it reads and how it counts. Pure, so the + * window (useFind) and the tests agree on every number. Text is compared + * with whitespace collapsed and case folded, the same way on both sides: + * transcript items here, rendered rows in findHighlight. */ + +/** The conversation's live window. Saved history pages use their first + * event cursor, which is always positive. */ +export const LIVE_PAGE = 0; + +/** Lower case that keeps every character at its index, so offsets in the + * folded text are offsets in the original (a few characters, like "İ", + * lower-case to two). */ +export function foldCase(text: string): string { + const lower = text.toLowerCase(); + if (lower.length === text.length) return lower; + let out = ""; + for (let i = 0; i < text.length; i++) { + const folded = text[i].toLowerCase(); + out += folded.length === 1 ? folded : text[i]; + } + return out; +} + +/** The query as matched: trimmed, whitespace collapsed, case folded. Empty + * means there is nothing to find. */ +export function findNeedle(query: string): string { + return foldCase(query.trim().replace(/\s+/g, " ")); +} + +const CODE_SPAN = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g; + +/** Inline Markdown outside code spans, as it reads on screen. */ +function inlineText(text: string): string { + // Escaped punctuation is literal: set it aside (as private-use + // characters) so no rule below reads it as markup. + let out = text + .replace(/\\([\\`*_{}[\]()#+\-.!|~<>])/g, (_, char: string) => + String.fromCharCode(0xe000 + char.charCodeAt(0)), + ) + .replace( + /!\[([^\]]*)\]\([^)]*\)/g, + (_, alt: string) => + // Markdown.tsx shows images as a placeholder. + `[Image: ${alt || "attachment"}]`, + ) + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/<((?:https?|mailto):[^>\s]+)>/g, "$1"); + // Emphasis, innermost last: **strong**, __strong__, ~~strike~~, *em*, _em_ + // (underscores inside words, as in snake_case, are not emphasis). + for (let pass = 0; pass < 2; pass++) + out = out + .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, "$1") + .replace(/(^|[^\w_])__(?=\S)([\s\S]*?\S)__(?![\w_])/g, "$1$2") + .replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1") + .replace(/\*(?=[^\s*])([^*]*?[^\s*])\*/g, "$1") + .replace(/(^|[^\w_])_(?=[^\s_])([^_]*?[^\s_])_(?![\w_])/g, "$1$2"); + return out.replace(/[\ue000-\ue07f]/g, (char) => + String.fromCharCode(char.charCodeAt(0) - 0xe000), + ); +} + +/** One line of Markdown outside a fenced block, without its block markers. */ +function lineText(line: string): string { + if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) return ""; + // A table's delimiter row; its other rows read cell by cell. + if (/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(line)) return ""; + let text = line + .replace(/^\s{0,3}(>\s?)+/, "") + .replace(/^\s{0,3}#{1,6}(\s+|$)/, "") + .replace(/\s+#+\s*$/, "") + .replace(/^\s*([-*+]|\d{1,9}[.)])\s+(\[[ xX]\]\s+)?/, ""); + if (/^\s*\|/.test(line)) text = text.replace(/\|/g, " "); + let out = ""; + let last = 0; + for (const match of text.matchAll(CODE_SPAN)) { + out += inlineText(text.slice(last, match.index)); + // One space just inside both backtick runs is padding, not content. + const code = match[2]; + out += /^ [\s\S]* $/.test(code) && code.trim() ? code.slice(1, -1) : code; + last = match.index + match[0].length; + } + return out + inlineText(text.slice(last)); +} + +/** An answer's Markdown as it reads on screen, near enough to count what + * find would highlight: block and inline markup removed, code kept as is. */ +export function markdownText(source: string): string { + const lines: string[] = []; + let fence = ""; + for (const line of source.split("\n")) { + const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/)?.[1]; + if (fence) { + if (marker && marker[0] === fence[0] && marker.length >= fence.length) + fence = ""; + else lines.push(line); + } else if (marker) fence = marker; + else lines.push(lineText(line)); + } + return lines.join("\n"); +} + +/** The text of an item that find reads: prompts, answers and notes. Cards, + * tool steps (in collapsed timelines) and summaries are not searched. */ +export function itemText(item: ChatItem): string | null { + if (item.kind === "user" || item.kind === "note") return item.text; + if (item.kind === "agent") return markdownText(item.text); + return null; +} + +/** Items are replaced, never changed, when an event updates them, so their + * searchable text is worked out once per item. */ +const searchable = new WeakMap(); + +/** An item's text as find compares it, or null when it is not searched. */ +export function searchText(item: ChatItem): string | null { + let text = searchable.get(item); + if (text === undefined) { + const raw = itemText(item); + text = raw === null ? null : foldCase(raw.replace(/\s+/g, " ")); + searchable.set(item, text); + } + return text; +} + +/** One searchable row: its transcript row key and text. */ +export type FindDoc = { key: string; text: string }; + +/** The rows find reads, keyed as TranscriptRows keys them. `skip` leaves out + * rows that are not drawn (a queued follow-up shows in the queue). */ +export function findDocs( + items: ChatItem[], + skip?: (item: ChatItem) => boolean, +): FindDoc[] { + const docs: FindDoc[] = []; + items.forEach((item, index) => { + const text = searchText(item); + if (text && !skip?.(item)) + docs.push({ key: item.key || `i:${index}`, text }); + }); + return docs; +} + +/** Occurrences of `needle` in `text`, not overlapping (as browsers count). */ +export function countIn(text: string, needle: string): number { + if (!needle) return 0; + let count = 0; + for ( + let at = text.indexOf(needle); + at >= 0; + at = text.indexOf(needle, at + needle.length) + ) + count++; + return count; +} + +/** Where each occurrence of `needle` starts in `text`. */ +export function offsetsIn(text: string, needle: string): number[] { + const offsets: number[] = []; + if (!needle) return offsets; + for ( + let at = text.indexOf(needle); + at >= 0; + at = text.indexOf(needle, at + needle.length) + ) + offsets.push(at); + return offsets; +} + +/** Rows with at least one match, in order, and how many each has. */ +export function hitsIn( + docs: FindDoc[], + needle: string, +): { key: string; count: number }[] { + const hits: { key: string; count: number }[] = []; + if (!needle) return hits; + for (const doc of docs) { + const count = countIn(doc.text, needle); + if (count) hits.push({ key: doc.key, count }); + } + return hits; +} + +/** One match: the page and row it is in, and which of the row's matches. */ +export type FindMatch = { page: number; key: string; occurrence: number }; + +/** Every match of the conversation, oldest first. Rows hold their counts + * (not one entry per match), so a one-letter query in a long conversation + * stays small. */ +export type FindResults = { + hits: { page: number; key: string; count: number }[]; + /** The index of each row's first match. */ + starts: number[]; + total: number; + lookup: Map; +}; + +const hitId = (page: number, key: string) => `${page}\u0000${key}`; + +/** One result list from each page's hits, pages given oldest first. */ +export function combineHits( + pages: { page: number; hits: { key: string; count: number }[] }[], +): FindResults { + const results: FindResults = { + hits: [], + starts: [], + total: 0, + lookup: new Map(), + }; + for (const { page, hits } of pages) + for (const { key, count } of hits) { + results.lookup.set(hitId(page, key), results.hits.length); + results.hits.push({ page, key, count }); + results.starts.push(results.total); + results.total += count; + } + return results; +} + +/** The match at a position (0-based), or null outside the list. */ +export function matchAt(results: FindResults, index: number): FindMatch | null { + if (index < 0 || index >= results.total) return null; + let low = 0; + let high = results.hits.length - 1; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (results.starts[mid] <= index) low = mid; + else high = mid - 1; + } + const hit = results.hits[low]; + return { + page: hit.page, + key: hit.key, + occurrence: index - results.starts[low], + }; +} + +/** A match's position, or -1 when the list no longer has it. */ +export function matchIndex( + results: FindResults, + match: FindMatch | null, +): number { + if (!match) return -1; + const at = results.lookup.get(hitId(match.page, match.key)); + if (at === undefined || match.occurrence >= results.hits[at].count) return -1; + return results.starts[at] + match.occurrence; +} + +/** The match a new query starts on: the latest one on the page being read, + * else the latest in the conversation. */ +export function firstMatch( + results: FindResults, + page: number, +): FindMatch | null { + for (let i = results.hits.length - 1; i >= 0; i--) + if (results.hits[i].page === page) + return matchAt(results, results.starts[i] + results.hits[i].count - 1); + return matchAt(results, results.total - 1); +} + +/** The next (1) or previous (-1) position, wrapping at either end. */ +export function stepIndex(index: number, total: number, by: 1 | -1): number { + if (total <= 0) return -1; + if (index < 0) return by === 1 ? 0 : total - 1; + return (index + by + total) % total; +} + +const count = (n: number) => n.toLocaleString("en-US"); + +/** "3 of 12", "No matches", or "12 matches" before one is chosen. */ +export function findLabel(index: number, total: number): string { + if (total <= 0) return "No matches"; + if (index < 0) return `${count(total)} ${total === 1 ? "match" : "matches"}`; + return `${count(index + 1)} of ${count(total)}`; +} diff --git a/ui/src/lib/findHighlight.test.ts b/ui/src/lib/findHighlight.test.ts new file mode 100644 index 00000000..87e762b0 --- /dev/null +++ b/ui/src/lib/findHighlight.test.ts @@ -0,0 +1,143 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + FIND_CURRENT_ATTR, + HIGHLIGHT_ALL, + HIGHLIGHT_CURRENT, + canHighlight, + clearMatches, + markRow, + paintMatches, + rangesIn, + rowText, + scrollToTarget, + smoothScrolling, +} from "./findHighlight"; + +afterEach(() => { + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +function row(html: string): HTMLElement { + document.body.innerHTML = `
${html}
`; + return document.querySelector("[data-find-row]")!; +} + +const answer = () => + row( + '
Result
' + + '

Fixed the Bug.

\n' + + '
bug()\n
' + + '
', + ); + +it("reads a row's drawn text: blocks apart, controls and labels left out", () => { + expect(rowText(answer()).text).toBe("Fixed the Bug. bug() "); + const prompt = row( + '
Fix\n\nthe bug
' + + '
', + ); + expect(rowText(prompt).text).toBe("Fix the bug"); + // A prompt being edited is a form, not the message. + const editing = row( + '
', + ); + expect(rowText(editing).text).toBe(""); + expect(rowText(row('
Using Codex
')).text).toBe( + "Using Codex", + ); +}); + +it("finds every match as a Range over the drawn text, across elements", () => { + const ranges = rangesIn(answer(), "bug"); + expect(ranges.map(String)).toEqual(["Bug", "bug"]); + expect(rangesIn(answer(), "the bug").map(String)).toEqual(["the Bug"]); + // Collapsed spaces still map back to the text as written. + expect(rangesIn(answer(), "fixed the").map(String)).toEqual(["Fixed the"]); + expect(rangesIn(answer(), "")).toEqual([]); + expect(rangesIn(answer(), "copy")).toEqual([]); +}); + +class FakeHighlight extends Set { + priority = 0; + constructor(...ranges: Range[]) { + super(ranges); + } +} + +it("paints matches with the highlight API where it exists", () => { + expect(canHighlight()).toBe(false); + const [first, second] = rangesIn(answer(), "bug"); + expect(paintMatches([first], second)).toBe(false); + + const registry = new Map(); + vi.stubGlobal("CSS", { highlights: registry }); + vi.stubGlobal("Highlight", FakeHighlight); + expect(canHighlight()).toBe(true); + expect(paintMatches([first], second)).toBe(true); + expect([...registry.get(HIGHLIGHT_ALL)!]).toEqual([first]); + expect([...registry.get(HIGHLIGHT_CURRENT)!]).toEqual([second]); + expect(registry.get(HIGHLIGHT_CURRENT)!.priority).toBe(1); + paintMatches([first, second], null); + expect(registry.has(HIGHLIGHT_CURRENT)).toBe(false); + clearMatches(document.body); + expect(registry.size).toBe(0); +}); + +it("marks one row as the current match's", () => { + document.body.innerHTML = + '
'; + const stream = document.getElementById("stream")!; + const a = document.getElementById("a")!; + const b = document.getElementById("b")!; + markRow(stream, a, "row"); + expect(a.getAttribute(FIND_CURRENT_ATTR)).toBe("row"); + markRow(stream, b, "text"); + expect(a.hasAttribute(FIND_CURRENT_ATTR)).toBe(false); + expect(b.getAttribute(FIND_CURRENT_ATTR)).toBe("text"); + clearMatches(stream); + expect(b.hasAttribute(FIND_CURRENT_ATTR)).toBe(false); +}); + +function box(top: number, height: number) { + return { top, bottom: top + height, height } as DOMRect; +} + +it("scrolls a match to the middle only when it is out of view", () => { + const stream = document.createElement("div"); + const target = document.createElement("div"); + stream.getBoundingClientRect = () => box(100, 600); + Object.defineProperty(stream, "scrollTop", { value: 1000 }); + const scrollTo = vi.fn(); + stream.scrollTo = scrollTo as unknown as typeof stream.scrollTo; + + target.getBoundingClientRect = () => box(300, 40); + expect(scrollToTarget(stream, target, true)).toBe(false); + expect(scrollTo).not.toHaveBeenCalled(); + + target.getBoundingClientRect = () => box(1500, 40); + expect(scrollToTarget(stream, target, true)).toBe(true); + expect(scrollTo).toHaveBeenLastCalledWith({ + top: 1000 + 1500 - 100 - 280, + behavior: "smooth", + }); + // A row taller than the view shows from its top. + target.getBoundingClientRect = () => box(-2000, 900); + scrollToTarget(stream, target, false); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "auto" }); +}); + +it("does not scroll smoothly when reduced motion is asked for", () => { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + })), + ); + expect(smoothScrolling()).toBe(false); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false })), + ); + expect(smoothScrolling()).toBe(true); +}); diff --git a/ui/src/lib/findHighlight.ts b/ui/src/lib/findHighlight.ts new file mode 100644 index 00000000..b738a09b --- /dev/null +++ b/ui/src/lib/findHighlight.ts @@ -0,0 +1,194 @@ +import { foldCase, offsetsIn } from "./find"; + +/** Find in conversation, on screen: the text a transcript row draws, the + * CSS Custom Highlight API where the webview has it (older WebKitGTK does + * not), and scrolling a match into view. */ + +/** TranscriptRows marks each searchable row with its key. */ +export const FIND_ROW_ATTR = "data-find-row"; +/** The row holding the current match. "text" when the match itself is + * highlighted, "row" when only the row can be marked (no highlight API, or + * the drawn text differs from what was counted). */ +export const FIND_CURRENT_ATTR = "data-find-current"; + +export const HIGHLIGHT_ALL = "shadow-find"; +export const HIGHLIGHT_CURRENT = "shadow-find-current"; + +/** Where a row draws the text find reads: an answer's Markdown, a prompt's + * bubble (not its edit form) and a note. */ +const TEXT = ".msg-agent > .markdown, .user-pill > div.bubble, .msg-note"; +/** Controls inside that text (a code block's Copy) are not part of it. */ +const SKIP = "button, .markdown-preview"; +/** Text in different blocks reads as separate words. */ +const BLOCK = + "p, li, pre, td, th, h1, h2, h3, h4, h5, h6, blockquote, div, dd, dt, tr"; + +/** The characters `\s` matches, so both sides collapse the same spaces. */ +function isSpace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || + code === 0x2029 || + code === 0x202f || + code === 0x205f || + code === 0x3000 || + code === 0xfeff + ); +} + +/** A row's drawn text as find compares it (whitespace collapsed, blocks + * apart), with each character's text node (-1 for a gap between blocks) and + * offset in it. */ +export function rowText(row: Element): { + text: string; + nodes: Text[]; + node: number[]; + offset: number[]; +} { + const nodes: Text[] = []; + const node: number[] = []; + const offset: number[] = []; + const chars: string[] = []; + let space = true; + let block: Element | null = null; + for (const root of row.querySelectorAll(TEXT)) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let next = walker.nextNode(); next; next = walker.nextNode()) { + const parent = next.parentElement; + if (!parent || parent.closest(SKIP)) continue; + const own = parent.closest(BLOCK); + if (own !== block) { + if (!space) { + chars.push(" "); + node.push(-1); + offset.push(0); + space = true; + } + block = own; + } + const data = (next as Text).data; + const index = nodes.push(next as Text) - 1; + for (let i = 0; i < data.length; i++) { + if (isSpace(data.charCodeAt(i))) { + if (space) continue; + chars.push(" "); + space = true; + } else { + chars.push(data[i]); + space = false; + } + node.push(index); + offset.push(i); + } + } + } + return { text: chars.join(""), nodes, node, offset }; +} + +/** A Range for each match of `needle` (from findNeedle) in a row's text. */ +export function rangesIn(row: Element, needle: string): Range[] { + if (!needle) return []; + const { text, nodes, node, offset } = rowText(row); + // The needle neither starts nor ends with a space, so both ends of a + // match are characters of a text node. + return offsetsIn(foldCase(text), needle).map((start) => { + const end = start + needle.length - 1; + const range = document.createRange(); + range.setStart(nodes[node[start]], offset[start]); + range.setEnd(nodes[node[end]], offset[end] + 1); + return range; + }); +} + +/** The CSS Custom Highlight API (Chromium, newer WebKitGTK). */ +export function canHighlight(): boolean { + return ( + typeof CSS !== "undefined" && + "highlights" in CSS && + typeof Highlight === "function" + ); +} + +/** Highlights every match and the current one. False when the webview + * cannot, so the caller marks the row instead. */ +export function paintMatches(all: Range[], current: Range | null): boolean { + if (!canHighlight()) return false; + const every = new Highlight(); + for (const range of all) every.add(range); + CSS.highlights.set(HIGHLIGHT_ALL, every); + if (current) { + const highlight = new Highlight(current); + highlight.priority = 1; + CSS.highlights.set(HIGHLIGHT_CURRENT, highlight); + } else CSS.highlights.delete(HIGHLIGHT_CURRENT); + return true; +} + +export function clearMatches(root?: Element | null) { + if (canHighlight()) { + CSS.highlights.delete(HIGHLIGHT_ALL); + CSS.highlights.delete(HIGHLIGHT_CURRENT); + } + markRow(root, null, "row"); +} + +/** Marks the current match's row (and unmarks the previous one). */ +export function markRow( + root: Element | null | undefined, + row: Element | null, + how: "text" | "row", +) { + root?.querySelectorAll(`[${FIND_CURRENT_ATTR}]`).forEach((marked) => { + if (marked !== row) marked.removeAttribute(FIND_CURRENT_ATTR); + }); + if (row && row.getAttribute(FIND_CURRENT_ATTR) !== how) + row.setAttribute(FIND_CURRENT_ATTR, how); +} + +/** What to scroll to: the match, else its row's first box (rows are + * `display: contents`, so the wrapper itself has none). */ +export function revealTarget( + row: Element, + range: Range | null, +): Range | Element { + return range || row.firstElementChild || row; +} + +const MARGIN = 48; + +/** The target is inside the scrolled view, clear of its edges. */ +export function inView(stream: Element, target: Range | Element): boolean { + const box = target.getBoundingClientRect(); + const view = stream.getBoundingClientRect(); + return box.top >= view.top + MARGIN && box.bottom <= view.bottom - MARGIN; +} + +/** Scrolls the stream so the target sits in the middle (a tall row: its + * top), only when it is not already in view. */ +export function scrollToTarget( + stream: Element, + target: Range | Element, + smooth: boolean, +): boolean { + if (inView(stream, target)) return false; + const box = target.getBoundingClientRect(); + const view = stream.getBoundingClientRect(); + const offset = + box.height > view.height - 2 * MARGIN + ? MARGIN + : (view.height - box.height) / 2; + stream.scrollTo({ + top: Math.max(0, stream.scrollTop + box.top - view.top - offset), + behavior: smooth ? "smooth" : "auto", + }); + return true; +} + +/** Smooth scrolling unless the system asks for reduced motion. */ +export function smoothScrolling(): boolean { + return !window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; +} From d9d98f00dd83f9c0f8fc23a99980a88f5b5e2763 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:38:05 +0000 Subject: [PATCH 08/28] Appearance: text size, live preview and every theme from the palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface had no zoom: sizes are fixed pixels, Ctrl+= and Ctrl+- did nothing, the theme on Settings › Appearance only showed after Save, and the palette's light/dark toggle could never return to System. Text size (`ui.zoom`, 80–175%) steps with Ctrl+= / Ctrl++ (Shift and the keypad too), Ctrl+- and Ctrl+0, under the shortcut rules that already exist: a terminal keeps the keys (Ctrl+- is readline's undo) and dialogs own theirs. A small status at the top right says the new size; each press replaces it rather than stacking notifications. The size applies at once and is saved once the keys stop, through the same deep-merged `ui` config save as the Appearance page, and mirrored in browser storage like the theme so a reload opens at the right size before the config is read. The desktop zooms its webview (`setZoom`, which needs core:webview:allow-set-webview-zoom in the main capability), so layout, media queries and the terminal's and editor's pointer maths behave as under browser zoom. A plain browser (remote access, the test suite) cannot set its own zoom and gets CSS `zoom` on the root, which needed three corrections, each covered by the new Playwright spec: - Chromium scales `vh`/`vw` inside a zoomed root by the zoom, so Settings and the terminal grew past the window. A small PostCSS step divides every viewport length by a `--zoom` property that is only set in this mode (it divides by 1 on the desktop). - xterm.js maps the pointer with window coordinates over cell sizes it measured unzoomed, so selections landed on the wrong cells. The terminal undoes the zoom on its own box and scales its font instead. - The conversation menu placed at the pointer divides by the zoom. CodeMirror already handles a scaled editor; the spec checks a click. Settings › Appearance gains a Text size control (the house segmented control, with Reset to 100%). Theme and text size now preview as they are picked; leaving the page or Settings without saving restores the saved ones, and only the saved values are remembered for the next start. The palette offers Theme: same as the system / light / dark (marking the one in use) and Zoom in, Zoom out and Reset zoom with their keys. The keys are in the shortcuts dialog and the user guide has an Appearance section. The Playwright fake can keep the `ui` group across a reload (keepUi) so the spec can check persistence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fn7NbipAkYG7cYZxHk8xRu --- docs/USER_GUIDE.md | 22 ++ src-tauri/capabilities/main.json | 6 +- ui/e2e/appearance.spec.ts | 333 ++++++++++++++++++ ui/e2e/fakeBackend.ts | 16 +- ui/src/App.tsx | 34 +- ui/src/appearance.css | 37 ++ ui/src/components/ConversationMenu.tsx | 8 +- ui/src/components/Settings.tsx | 6 +- ui/src/components/TerminalView.tsx | 26 +- ui/src/components/overlays.tsx | 8 + .../settings/AppearancePage.test.tsx | 66 ++++ .../components/settings/PreferencePages.tsx | 72 +++- ui/src/components/shell/ZoomStatus.tsx | 30 ++ .../shell/palette.appearance.test.ts | 59 ++++ ui/src/components/shell/palette.test.ts | 3 +- ui/src/components/shell/palette.ts | 40 ++- ui/src/hooks/useAppearance.test.ts | 140 ++++++++ ui/src/hooks/useAppearance.ts | 99 ++++++ ui/src/hooks/useShortcuts.ts | 8 + ui/src/hooks/useShortcuts.zoom.test.ts | 78 ++++ ui/src/hooks/useTheme.ts | 16 +- ui/src/hooks/useZoom.test.ts | 94 +++++ ui/src/hooks/useZoom.ts | 83 +++++ ui/src/lib/zoom.test.ts | 81 +++++ ui/src/lib/zoom.ts | 66 ++++ ui/src/main.tsx | 5 +- ui/vite.config.ts | 16 + 27 files changed, 1409 insertions(+), 43 deletions(-) create mode 100644 ui/e2e/appearance.spec.ts create mode 100644 ui/src/appearance.css create mode 100644 ui/src/components/settings/AppearancePage.test.tsx create mode 100644 ui/src/components/shell/ZoomStatus.tsx create mode 100644 ui/src/components/shell/palette.appearance.test.ts create mode 100644 ui/src/hooks/useAppearance.test.ts create mode 100644 ui/src/hooks/useAppearance.ts create mode 100644 ui/src/hooks/useShortcuts.zoom.test.ts create mode 100644 ui/src/hooks/useZoom.test.ts create mode 100644 ui/src/hooks/useZoom.ts create mode 100644 ui/src/lib/zoom.test.ts create mode 100644 ui/src/lib/zoom.ts diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 58a10076..b3f35659 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -839,6 +839,28 @@ and `.env` files suggest. Nothing runs until you save it. in the agent's answers. When the title doesn't show the words, the result shows the message or answer that matched, with the words highlighted. +## Appearance and text size + +**Settings › Appearance** chooses the theme (*System* follows your +computer's light or dark setting) and the text size, from 80% to 175%. Both +change as you pick them, so you see the result before you keep it: **Save** +keeps them, and leaving the page or Settings without saving puts back what +you had. **Reset to 100%** goes back to the normal size. + +Anywhere in the window: + +- `Ctrl+=` (or `Ctrl++`) makes everything larger, `Ctrl+-` smaller, and + `Ctrl+0` goes back to 100%. The new size shows for a moment at the top + right and is saved straight away. +- Inside a terminal these keys belong to the shell, and in a dialog to the + dialog. +- The command palette (`Ctrl+K`) has *Theme: same as the system*, *Theme: + light* and *Theme: dark*, and *Zoom in*, *Zoom out* and *Reset zoom to + 100%*. + +The keyboard shortcuts (`?`) list these keys too. The theme and size are +part of your settings, so a browser using remote access gets them as well. + ## Notifications When the window is in the background, or the task is in another diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index b2e3f179..e63b6e0c 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -3,5 +3,9 @@ "identifier": "main", "description": "Embedded ShadowCode interface only; all filesystem and process actions use the guarded Rust command service.", "windows": ["main"], - "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-set-webview-zoom" + ] } diff --git a/ui/e2e/appearance.spec.ts b/ui/e2e/appearance.spec.ts new file mode 100644 index 00000000..ab11c136 --- /dev/null +++ b/ui/e2e/appearance.spec.ts @@ -0,0 +1,333 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; +import { installFakeTools } from "./fakeTools"; +import { expectEditorText } from "./editorHelpers"; + +// Text size and theme: the zoom keys, Settings › Appearance's live preview, +// the command palette, and the CSS zoom a browser falls back to (the desktop +// zooms its webview instead) keeping the terminal and editor accurate. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { stepMs: 60, keepUi: true }); + await page.addInitScript(installFakeTools, {}); + // The size the window opened at, before the config was read. + await page.addInitScript(() => + document.addEventListener("DOMContentLoaded", () => { + (window as any).__zoomAtStart = + document.documentElement.style.zoom || "1"; + }), + ); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +const zoom = (page: Page) => + page.evaluate(() => document.documentElement.style.zoom || "1"); +const theme = (page: Page) => + page.evaluate(() => document.documentElement.dataset.theme); +const savedUi = (page: Page) => + page.evaluate(() => + ( + (window as any).__SHADOW_FAKE__.log as { + method: string; + path: string; + body: { values?: { ui?: Record } }; + }[] + ) + .filter((r) => r.method === "PUT" && r.path === "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/api/config") + .map((r) => r.body.values?.ui), + ); +const both = async (page: Page) => [await theme(page), await zoom(page)]; +const status = (page: Page) => + page.getByRole("status").filter({ hasText: /^Text size/ }); +const heading = (page: Page) => + page.getByRole("heading", { name: "What should we work on?" }); + +test("Ctrl+=, Ctrl+- and Ctrl+0 zoom the window, say the size and keep it", async ({ + page, +}) => { + const button = page.getByRole("button", { name: "Review changes" }); + const before = (await button.boundingBox())!; + expect(await zoom(page)).toBe("1"); + await page.keyboard.press("Control+Equal"); + await expect.poll(() => zoom(page)).toBe("1.1"); + await expect(status(page)).toHaveText("Text size 110%"); + // Ctrl++ (Shift, or the keypad) zooms in too; Ctrl+- zooms out. + await page.keyboard.press("Control+Shift+Equal"); + await expect.poll(() => zoom(page)).toBe("1.25"); + await page.keyboard.press("Control+NumpadAdd"); + await expect.poll(() => zoom(page)).toBe("1.5"); + await page.keyboard.press("Control+Minus"); + await expect.poll(() => zoom(page)).toBe("1.25"); + await expect(status(page)).toHaveText("Text size 125%"); + await page.keyboard.press("Control+0"); + await expect.poll(() => zoom(page)).toBe("1"); + // From the composer as well, up to the largest size. + await page.getByRole("textbox", { name: "Message ShadowCode" }).focus(); + for (let i = 0; i < 6; i++) await page.keyboard.press("Control+Equal"); + await expect.poll(() => zoom(page)).toBe("1.75"); + await expect(status(page)).toHaveText("Text size 175% · largest"); + // The whole window is larger, not just the text. + const after = (await button.boundingBox())!; + for (const ratio of [ + after.height / before.height, + after.width / before.width, + ]) { + // Borders and text snap to whole pixels, so it is close to 1.75. + expect(ratio).toBeGreaterThan(1.65); + expect(ratio).toBeLessThan(1.85); + } + // Saved once the keys stop, and remembered for the next start. + await expect + .poll(() => savedUi(page).then((saved) => saved.at(-1))) + .toEqual({ zoom: 1.75 }); + expect(await page.evaluate(() => localStorage.getItem("shadow:zoom"))).toBe( + "1.75", + ); + await expect(page.locator(".zoom-status")).toBeEmpty(); + // A dialog keeps its keys. + await page.keyboard.press("Control+,"); + await expect(page.getByRole("dialog", { name: "Settings" })).toBeVisible(); + await page.keyboard.press("Control+Minus"); + await page.keyboard.press("Escape"); + expect(await zoom(page)).toBe("1.75"); + // A reload opens at the saved size before the config has been read. + await page.reload(); + await expect(heading(page)).toBeVisible(); + expect(await page.evaluate(() => (window as any).__zoomAtStart)).toBe("1.75"); + expect(await zoom(page)).toBe("1.75"); +}); + +test("Settings › Appearance shows theme and text size as they are picked, and only Save keeps them", async ({ + page, +}) => { + const settings = page.getByRole("dialog", { name: "Settings" }); + const open = async () => { + await page.keyboard.press("Control+,"); + await settings + .getByRole("button", { name: "Appearance", exact: true }) + .click(); + }; + const option = (group: string, name: string) => + settings + .getByRole("radiogroup", { name: group }) + .getByRole("radio", { name, exact: true }); + await open(); + await expect(option("Theme", "Light")).toHaveAttribute( + "aria-checked", + "true", + ); + await expect(option("Text size", "100%")).toHaveAttribute( + "aria-checked", + "true", + ); + const reset = settings.getByRole("button", { name: "Reset to 100%" }); + await expect(reset).toBeDisabled(); + await option("Theme", "Dark").click(); + await expect.poll(() => theme(page)).toBe("dark"); + await option("Text size", "150%").click(); + await expect.poll(() => zoom(page)).toBe("1.5"); + // The dialog still fits the window at that size. + const box = (await settings.boundingBox())!; + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.y + box.height).toBeLessThanOrEqual(1000); + // Another page puts the saved ones back… + await settings.getByRole("button", { name: "Accounts", exact: true }).click(); + await expect.poll(() => both(page)).toEqual(["light", "1"]); + await settings + .getByRole("button", { name: "Appearance", exact: true }) + .click(); + await expect(option("Theme", "Light")).toHaveAttribute( + "aria-checked", + "true", + ); + // …and so does closing Settings, with Close or with Escape. + await option("Theme", "Dark").click(); + await option("Text size", "125%").click(); + await settings.getByRole("button", { name: "Close", exact: true }).click(); + await expect(settings).toHaveCount(0); + await expect.poll(() => both(page)).toEqual(["light", "1"]); + await open(); + await option("Text size", "90%").click(); + await expect.poll(() => zoom(page)).toBe("0.9"); + await page.keyboard.press("Escape"); + await expect(settings).toHaveCount(0); + await expect.poll(() => zoom(page)).toBe("1"); + expect(await savedUi(page)).toEqual([]); + // Save keeps them, with the notification choices. + await open(); + await option("Theme", "Dark").click(); + await option("Text size", "125%").click(); + await expect(reset).toBeEnabled(); + await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .include(".settings") + .analyze() + .then((results) => expect(results.violations).toEqual([])); + await settings.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Settings saved")).toBeVisible(); + expect(await savedUi(page)).toEqual([ + expect.objectContaining({ theme: "dark", zoom: 1.25, notify: true }), + ]); + await settings.getByRole("button", { name: "Close", exact: true }).click(); + await expect.poll(() => both(page)).toEqual(["dark", "1.25"]); + expect( + await page.evaluate(() => [ + localStorage.getItem("shadow:theme"), + localStorage.getItem("shadow:zoom"), + ]), + ).toEqual(["dark", "1.25"]); + // Reset previews 100% until Save; leaving brings back the saved 125%. + await open(); + await reset.click(); + await expect.poll(() => zoom(page)).toBe("1"); + await expect(option("Text size", "100%")).toHaveAttribute( + "aria-checked", + "true", + ); + await page.keyboard.press("Escape"); + await expect.poll(() => zoom(page)).toBe("1.25"); + await page.reload(); + await expect(heading(page)).toBeVisible(); + await expect.poll(() => both(page)).toEqual(["dark", "1.25"]); +}); + +test("the command palette reaches every theme and the text size", async ({ + page, +}) => { + const run = async (command: string) => { + await page.keyboard.press("Control+k"); + const palette = page.getByRole("dialog", { name: "Command palette" }); + await palette + .getByRole("textbox", { name: "Search commands" }) + .fill(command); + await page.keyboard.press("Enter"); + await expect(palette).toHaveCount(0); + }; + await page.keyboard.press("Control+k"); + const palette = page.getByRole("dialog", { name: "Command palette" }); + await palette.getByRole("textbox", { name: "Search commands" }).fill("Theme"); + const entry = (name: string) => + palette.getByRole("button", { name: new RegExp(`^Theme: ${name}`) }); + await expect(entry("same as the system")).toBeVisible(); + await expect(entry("dark")).toBeVisible(); + await expect(entry("light")).toContainText("In use"); + await expect(entry("dark")).not.toContainText("In use"); + await page.keyboard.press("Escape"); + await run("Theme: dark"); + await expect.poll(() => theme(page)).toBe("dark"); + // System follows the computer's setting, both ways. + await page.emulateMedia({ colorScheme: "light" }); + await run("Theme: same as the system"); + await expect.poll(() => theme(page)).toBe("light"); + await page.emulateMedia({ colorScheme: "dark" }); + await expect.poll(() => theme(page)).toBe("dark"); + await page.emulateMedia({ colorScheme: "light" }); + await expect.poll(() => theme(page)).toBe("light"); + await run("Zoom in"); + await expect.poll(() => zoom(page)).toBe("1.1"); + await expect(status(page)).toHaveText("Text size 110%"); + await run("Zoom out"); + await run("Zoom out"); + await expect.poll(() => zoom(page)).toBe("0.9"); + await run("Reset zoom"); + await expect.poll(() => zoom(page)).toBe("1"); + await expect + .poll(() => savedUi(page).then((saved) => saved.at(-1))) + .toEqual({ zoom: 1 }); + expect((await savedUi(page)).slice(0, 2)).toEqual([ + { theme: "dark" }, + { theme: "system" }, + ]); +}); + +test("zoomed with CSS, the terminal and the editor put the pointer on the right text", async ({ + page, +}) => { + await page.evaluate(() => { + const fake = (window as any).__SHADOW_FAKE__; + fake.state.files["README.md"] = "alpha beta gamma\n"; + }); + for (let i = 0; i < 3; i++) await page.keyboard.press("Control+Equal"); + await expect.poll(() => zoom(page)).toBe("1.5"); + const drawer = page.getByRole("complementary", { name: "Drawer" }); + await page.keyboard.press("Control+`"); + const terminal = drawer.getByRole("group", { name: "Terminal 1" }); + await expect(terminal).toContainText("demo$"); + await terminal.click(); + const echoed = "one two three four five"; + await page.keyboard.type(`echo ${echoed}`); + await page.keyboard.press("Enter"); + const rows = terminal.locator(".xterm-rows > div"); + await expect( + rows.filter({ hasText: new RegExp(`^${echoed}\\s*$`) }), + ).toHaveCount(1); + // The terminal fits the window (its height is a share of it). + const screen = (await terminal.boundingBox())!; + expect(screen.y + screen.height).toBeLessThanOrEqual(1000); + // Double-click "four" on the second row: the selection covers that word, + // not one further right or lower (where unscaled maths would land). + const word = await rows + .filter({ hasText: new RegExp(`^${echoed}\\s*$`) }) + .evaluate((row) => { + const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const at = node.data.indexOf("four"); + if (at < 0) continue; + const range = document.createRange(); + range.setStart(node, at); + range.setEnd(node, at + "four".length); + const r = range.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + } + throw new Error("echoed word not found"); + }); + await page.mouse.dblclick(word.x + word.width / 2, word.y + word.height / 2); + // xterm draws a first-row box and an empty one for the rows between. + const selection = () => + terminal.evaluate((element) => + [...element.querySelectorAll(".xterm-selection div")] + .map((div) => div.getBoundingClientRect()) + .filter((r) => r.height > 0) + .map((r) => ({ x: r.x, y: r.y, width: r.width })), + ); + await expect.poll(selection).toHaveLength(1); + const [selected] = await selection(); + expect(Math.abs(selected.x - word.x)).toBeLessThan(3); + expect(Math.abs(selected.y - word.y)).toBeLessThan(3); + expect(Math.abs(selected.width - word.width)).toBeLessThan(3); + // The editor: a click before "beta" puts the cursor there. + await drawer + .locator(".drawer-tabs") + .getByRole("button", { name: "Files" }) + .click(); + await drawer.getByRole("button", { name: "README.md", exact: true }).click(); + const editor = drawer.getByRole("textbox", { name: "Edit README.md" }); + await expectEditorText(editor, "alpha beta gamma\n"); + const b = await editor.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const at = node.data.indexOf("beta"); + if (at < 0) continue; + const range = document.createRange(); + range.setStart(node, at); + range.setEnd(node, at + 1); + const r = range.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + } + throw new Error("beta not found"); + }); + await page.mouse.click(b.x + b.width / 4, b.y + b.height / 2); + await page.keyboard.type("X"); + await expectEditorText(editor, "alpha Xbeta gamma\n"); +}); diff --git a/ui/e2e/fakeBackend.ts b/ui/e2e/fakeBackend.ts index 6155ca46..e07989bb 100644 --- a/ui/e2e/fakeBackend.ts +++ b/ui/e2e/fakeBackend.ts @@ -52,6 +52,9 @@ export type FakeOptions = { /** Earlier conversations in the project, by title, newest first (s2, * s3, …), all older than the open one. */ conversations?: string[]; + /** The `ui` settings group survives a reload of the window, as the + * engine's config file does (kept in this tab's session storage). */ + keepUi?: boolean; }; export function installFakeBackend(options: FakeOptions = {}) { @@ -368,7 +371,13 @@ export function installFakeBackend(options: FakeOptions = {}) { }, }, network: { mode: options.network || "online" }, - ui: { theme: "light", notify: true }, + ui: { + theme: "light", + notify: true, + ...(options.keepUi + ? JSON.parse(sessionStorage.getItem("shadow-fake-config-ui") || "{}") + : {}), + }, cli_agents: { enabled: true }, guardian: { enabled: false, interval_sec: 3600 }, limits: { on_limit: "local", fallback_model: "" }, @@ -2955,6 +2964,11 @@ export function installFakeBackend(options: FakeOptions = {}) { const values = body.values || {}; for (const [k, v] of Object.entries(values)) state.config[k] = { ...(state.config[k] || {}), ...(v as Json) }; + if (options.keepUi) + sessionStorage.setItem( + "shadow-fake-config-ui", + JSON.stringify(state.config.ui), + ); } return state.config; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 166eca0b..c74c0eca 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -12,9 +12,10 @@ import { BootScreen, Toasts } from "./components/shell/Chrome"; import { composerAccess } from "./components/shell/ComposerDock"; import { AppDialogs, type Overlay } from "./components/shell/AppDialogs"; import { paletteItems } from "./components/shell/palette"; +import { ZoomStatus } from "./components/shell/ZoomStatus"; import { useConversation } from "./hooks/useConversation"; import { useToasts } from "./hooks/useToasts"; -import { useTheme } from "./hooks/useTheme"; +import { useAppearance } from "./hooks/useAppearance"; import { useFeed } from "./hooks/useFeed"; import { useShortcuts, type ShortcutAction } from "./hooks/useShortcuts"; import { useWorkspace } from "./hooks/useWorkspace"; @@ -116,11 +117,12 @@ export default function App() { void allowance.reload(); }; const memory = useDrawerMemory(workspace); - useTheme( - ws.configLoaded - ? String((cfg.ui as { theme?: string } | undefined)?.theme || "system") - : undefined, - ); + const appearance = useAppearance({ + cfg, + loaded: ws.configLoaded, + reloadConfig: ws.reloadConfig, + toast, + }); function openSettings( section: SettingsSection = "accounts", @@ -478,6 +480,9 @@ export default function App() { export: () => exportSession(), help: () => setOverlay("help"), find: () => setFind((n) => n + 1), + "zoom-in": () => appearance.zoom(1), + "zoom-out": () => appearance.zoom(-1), + "zoom-reset": () => appearance.zoom(0), "previous-conversation": conversations.previous, "next-conversation": conversations.next, "recent-conversation": conversations.recent, @@ -637,18 +642,9 @@ export default function App() { openSettings(section, advanced ? { advanced } : {}), exportTask: exportSession, stop: () => void controls.stop(), - toggleTheme: () => - void api - .saveConfig({ - ui: { - theme: - document.documentElement.dataset.theme === "dark" - ? "light" - : "dark", - }, - }) - .then(ws.reloadConfig) - .catch((e) => toast(String(e), "err")), + theme: appearance.setTheme, + themeInUse: appearance.theme, + zoom: appearance.zoom, help: () => setOverlay("help"), find: () => setFind((n) => n + 1), }); @@ -837,6 +833,7 @@ export default function App() { /> )} + {rewinding.asking && ( void roles.reload(), } : undefined, + appearance: appearance.controls, onSave: async (values, options) => { try { await api.saveConfig(values); diff --git a/ui/src/appearance.css b/ui/src/appearance.css new file mode 100644 index 00000000..3afc4e41 --- /dev/null +++ b/ui/src/appearance.css @@ -0,0 +1,37 @@ +/* Text size ---------------------------------------------------------------- */ +/* The size for a moment after a zoom key: under the header, out of the way of + the notifications in the middle, and never in the way of a click. */ +.zoom-status { + position: fixed; + top: 62px; + right: 20px; + z-index: 40; + padding: 6px 12px; + border-radius: var(--pill); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease-out; +} +.zoom-status[data-shown] { + opacity: 1; +} +.zoom-status:empty { + padding: 0; + border: 0; +} + +/* Settings › Appearance: seven sizes wrap in a narrow window. */ +.text-size-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} +.text-size-steps { + flex-wrap: wrap; +} diff --git a/ui/src/components/ConversationMenu.tsx b/ui/src/components/ConversationMenu.tsx index 88a79beb..d96a40af 100644 --- a/ui/src/components/ConversationMenu.tsx +++ b/ui/src/components/ConversationMenu.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Download, GitFork, Pencil, Pin, Trash2 } from "lucide-react"; +import { cssZoom } from "../hooks/useZoom"; export type ConversationAction = "rename" | "pin" | "fork" | "export" | "delete"; @@ -44,8 +45,11 @@ export function ConversationMenu({ ["export", "Export…", Download], ["delete", "Delete…", Trash2], ]; - const left = Math.min(x, window.innerWidth - 200); - const top = Math.min(y, window.innerHeight - 220); + // The pointer is in window pixels; under CSS zoom (a plain browser, see + // useZoom) the menu is placed in zoomed ones. + const zoom = cssZoom(); + const left = Math.min(x / zoom, window.innerWidth / zoom - 200); + const top = Math.min(y / zoom, window.innerHeight / zoom - 220); return (
(initialSection); const [advanced, setAdvanced] = useState(initialAdvanced); @@ -154,7 +158,7 @@ export function Settings({ )} {section === "appearance" && ( - + )} {section === "remote" && } {section === "data" && } diff --git a/ui/src/components/TerminalView.tsx b/ui/src/components/TerminalView.tsx index bff0a6ce..a5018c5b 100644 --- a/ui/src/components/TerminalView.tsx +++ b/ui/src/components/TerminalView.tsx @@ -12,6 +12,18 @@ import { terminalApi, type TerminalChunk, } from "../lib/terminal"; +import { cssZoom, onCssZoom } from "../hooks/useZoom"; + +const FONT_SIZE = 12.5; + +/** xterm.js maps the pointer with window coordinates over cell sizes it + * measured unzoomed, so under a CSS-zoomed root (a plain browser) clicks + * and selections would land on the wrong cells. The terminal undoes the + * zoom on its own box and scales its font instead; the desktop's webview + * zoom needs neither (the CSS zoom is 1 there). */ +function unzoom(element: HTMLElement, zoom: number) { + element.style.zoom = zoom === 1 ? "" : String(1 / zoom); +} function theme(): ITheme { const css = getComputedStyle(document.documentElement); @@ -52,13 +64,14 @@ export function TerminalView({ cursorBlink: true, fontFamily: css.getPropertyValue("--mono").trim() || "ui-monospace, monospace", - fontSize: 12.5, + fontSize: FONT_SIZE * cssZoom(), scrollback: 5000, allowProposedApi: false, theme: theme(), }); const fit = new FitAddon(); term.loadAddon(fit); + unzoom(element, cssZoom()); term.open(element); const input = inputQueue( (data) => terminalApi.input(id, data), @@ -98,6 +111,11 @@ export function TerminalView({ }); const observer = new ResizeObserver(() => refit()); observer.observe(element); + const stopZoom = onCssZoom((zoom) => { + unzoom(element, zoom); + term.options.fontSize = FONT_SIZE * zoom; + refit(); + }); const recolour = new MutationObserver(() => { term.options.theme = theme(); }); @@ -108,6 +126,7 @@ export function TerminalView({ term.focus(); return () => { stopWakeups(); + stopZoom(); observer.disconnect(); recolour.disconnect(); reader.stop(); @@ -121,7 +140,6 @@ export function TerminalView({ return (
+ > +
+
); } diff --git a/ui/src/components/overlays.tsx b/ui/src/components/overlays.tsx index 74d08244..c306bbb7 100644 --- a/ui/src/components/overlays.tsx +++ b/ui/src/components/overlays.tsx @@ -33,6 +33,14 @@ const SHORTCUT_GROUPS: { label: string; rows: [string, string][] }[] = [ ["Esc", "Close overlay or drawer"], ], }, + { + label: "Text size", + rows: [ + ["Ctrl+=", "Zoom in (larger text)"], + ["Ctrl+-", "Zoom out (smaller text)"], + ["Ctrl+0", "Reset to 100%"], + ], + }, { label: "Other", rows: [ diff --git a/ui/src/components/settings/AppearancePage.test.tsx b/ui/src/components/settings/AppearancePage.test.tsx new file mode 100644 index 00000000..bd06eacb --- /dev/null +++ b/ui/src/components/settings/AppearancePage.test.tsx @@ -0,0 +1,66 @@ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { AppearancePage } from "./PreferencePages"; + +afterEach(() => cleanup()); + +const group = (name: string) => screen.getByRole("radiogroup", { name }); +const radio = (name: string, option: string) => + within(group(name)).getByRole("radio", { name: option }); + +it("previews theme and text size as they are picked, and saves them with the notifications", async () => { + const preview = vi.fn(); + const onSave = vi.fn(async () => undefined); + const { unmount } = render( + , + ); + expect(radio("Theme", "Light").getAttribute("aria-checked")).toBe("true"); + expect(radio("Text size", "110%").getAttribute("aria-checked")).toBe("true"); + expect( + within(group("Text size")) + .getAllByRole("radio") + .map((r) => r.textContent), + ).toEqual(["80%", "90%", "100%", "110%", "125%", "150%", "175%"]); + // Opening the page previews nothing. + expect(preview).not.toHaveBeenCalled(); + fireEvent.click(radio("Theme", "Dark")); + expect(preview).toHaveBeenLastCalledWith({ theme: "dark", zoom: 1.1 }); + fireEvent.click(radio("Text size", "150%")); + expect(preview).toHaveBeenLastCalledWith({ theme: "dark", zoom: 1.5 }); + expect(radio("Text size", "150%").getAttribute("aria-checked")).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Reset to 100%" })); + expect(preview).toHaveBeenLastCalledWith({ theme: "dark", zoom: 1 }); + expect(screen.getByRole("button", { name: "Reset to 100%" })).toHaveProperty( + "disabled", + true, + ); + fireEvent.click(radio("Text size", "125%")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(onSave).toHaveBeenCalledWith({ + ui: expect.objectContaining({ theme: "dark", zoom: 1.25, notify: false }), + }); + // Leaving the page goes back to whatever is saved. + unmount(); + expect(preview).toHaveBeenLastCalledWith(null); +}); + +it("without the window's controls it starts from the config", () => { + render( + undefined)} + />, + ); + expect(radio("Theme", "Dark").getAttribute("aria-checked")).toBe("true"); + expect(radio("Text size", "125%").getAttribute("aria-checked")).toBe("true"); +}); diff --git a/ui/src/components/settings/PreferencePages.tsx b/ui/src/components/settings/PreferencePages.tsx index a05fa74c..1870f48f 100644 --- a/ui/src/components/settings/PreferencePages.tsx +++ b/ui/src/components/settings/PreferencePages.tsx @@ -1,5 +1,12 @@ import { useEffect, useState } from "react"; import { api, type AlwaysAllowed } from "../../api"; +import type { AppearanceControls } from "../../hooks/useAppearance"; +import { + DEFAULT_ZOOM, + ZOOM_STEPS, + normalizeZoom, + zoomPercent, +} from "../../lib/zoom"; import { GitHooksSetting } from "./GitHooksSetting"; import { NotificationFields, notifyPrefs } from "./NotificationFields"; import { @@ -8,6 +15,7 @@ import { sandboxValues, } from "./SandboxSettings"; import "../../tools.css"; +import "../../appearance.css"; type Save = (values: Record) => Promise; @@ -258,21 +266,42 @@ export function PermissionsPage({ ); } -/** Settings › Appearance. Saves only the ui group. */ +/** Settings › Appearance. Saves only the ui group. Theme and text size show + * as they are picked; leaving the page or Settings without saving goes back + * to the saved ones (`appearance.preview(null)`). */ export function AppearancePage({ cfg, + appearance, onSave, }: { cfg: Record; + /** The window's saved theme and size, and its live preview. */ + appearance?: AppearanceControls; onSave: Save; }) { const ui = (cfg.ui || {}) as Record; - const [theme, setTheme] = useState(String(ui.theme || "system")); + const [theme, setTheme] = useState( + () => appearance?.theme ?? String(ui.theme || "system"), + ); + const [zoom, setZoom] = useState( + () => appearance?.zoom ?? normalizeZoom(ui.zoom), + ); const [notify, setNotify] = useState(() => notifyPrefs(ui)); const [saving, setSaving] = useState(false); + const preview = appearance?.preview; + useEffect(() => () => preview?.(null), [preview]); + const pick = (next: { theme?: string; zoom?: number }) => { + const shown = { theme, zoom, ...next }; + setTheme(shown.theme); + setZoom(shown.zoom); + preview?.(shown); + }; return (

Appearance

+

+ Theme and text size change as you choose them. Save keeps them. +

Theme
@@ -287,13 +316,48 @@ export function AppearancePage({ aria-checked={theme === id} key={id} className={theme === id ? "on" : ""} - onClick={() => setTheme(id)} + onClick={() => pick({ theme: id })} > {label} ))}
+
+ Text size +
+
+ {ZOOM_STEPS.map((step) => ( + + ))} +
+ +
+

+ Ctrl+= and Ctrl+- change it anywhere in the window, and Ctrl+0 resets + it. +

+
- - + + + +
+
)}
diff --git a/ui/src/components/MessageTime.tsx b/ui/src/components/MessageTime.tsx new file mode 100644 index 00000000..162bd6ee --- /dev/null +++ b/ui/src/components/MessageTime.tsx @@ -0,0 +1,32 @@ +import { useWallClock } from "../hooks/useWallClock"; +import { messageTime } from "../lib/messageTime"; + +/** A message's time, small and muted at its edge, with the full date and + * time as a tooltip. `quiet` times show only while the message is hovered + * or focused. Nothing is shown without a recorded time. It re-renders at + * midnight on its own, so "14:32" becomes "Yesterday 14:32" in a window + * left open. `now` and `locale` are for tests. */ +export function MessageTime({ + ts, + quiet = false, + now, + locale, +}: { + ts?: number; + quiet?: boolean; + now?: number; + locale?: string; +}) { + const current = useWallClock(now); + const time = ts ? messageTime(ts, current, locale) : null; + if (!time) return null; + return ( + + ); +} diff --git a/ui/src/components/ToolOutput.test.tsx b/ui/src/components/ToolOutput.test.tsx new file mode 100644 index 00000000..1d0b140d --- /dev/null +++ b/ui/src/components/ToolOutput.test.tsx @@ -0,0 +1,114 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { ToolOutput } from "./ToolOutput"; +import { ActivityTimeline } from "./ActivityTimeline"; +import { emptyActivity, type TaskActivity } from "../lib/activity"; +import { OUTPUT_MAX_LINES } from "../lib/toolOutput"; +import { COPIED_MS } from "../hooks/useCopyFeedback"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +const numbered = (count: number) => + Array.from({ length: count }, (_, i) => `line ${i + 1}`).join("\n"); + +it("long output shows all of it on request, and less again", () => { + const text = numbered(900); + const { container } = render(); + const pre = () => container.querySelector("pre")!.textContent; + expect(pre()).not.toContain("line 900"); + fireEvent.click( + screen.getByRole("button", { + name: "Show all (900 lines) of the output of npm test", + }), + ); + expect(pre()).toBe(text); + fireEvent.click( + screen.getByRole("button", { name: "Show less of the output of npm test" }), + ); + expect(pre()).not.toContain("line 900"); +}); + +it("huge output says which part it shows", () => { + const text = numbered(OUTPUT_MAX_LINES + 1); + render(); + fireEvent.click(screen.getByRole("button", { name: /^Show all/ })); + expect( + screen.getByText( + "Showing the last 2,000 of 2,001 lines. Copy includes all of it.", + ), + ).toBeTruthy(); +}); + +it("short output has Copy and nothing to expand", () => { + render(); + expect(screen.queryByRole("button", { name: /Show all/ })).toBeNull(); + expect( + screen.getByRole("button", { name: "Copy the output of npm test" }), + ).toBeTruthy(); +}); + +it("Copy copies the whole output and says Copied for a moment", async () => { + vi.useFakeTimers(); + const writeText = vi.fn(async () => {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const text = numbered(OUTPUT_MAX_LINES + 10); + render(); + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: "Copy the output of npm test" }), + ); + }); + expect(writeText).toHaveBeenCalledWith(text); + const done = screen.getByRole("button", { + name: "Copied the output of npm test", + }); + expect(done.textContent).toBe("Copied"); + act(() => { + vi.advanceTimersByTime(COPIED_MS); + }); + expect( + screen.getByRole("button", { name: "Copy the output of npm test" }) + .textContent, + ).toBe("Copy"); +}); + +it("a timeline step's output offers Show all and Copy for that command", () => { + const activity: TaskActivity = { + ...emptyActivity("t1"), + calls: [ + { + callId: "c1", + tool: "exec", + step: "testing", + label: "exec", + command: "npm test", + live: false, + ok: true, + output: numbered(600), + }, + ], + } as TaskActivity; + render(); + expect( + screen.getByRole("button", { + name: "Show all (600 lines) of the output of npm test", + hidden: true, + }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "Copy the output of npm test", + hidden: true, + }), + ).toBeTruthy(); +}); diff --git a/ui/src/components/ToolOutput.tsx b/ui/src/components/ToolOutput.tsx new file mode 100644 index 00000000..0ac0a9f0 --- /dev/null +++ b/ui/src/components/ToolOutput.tsx @@ -0,0 +1,57 @@ +import { memo, useMemo, useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { useCopyFeedback } from "../hooks/useCopyFeedback"; +import { outputView, showAllLabel } from "../lib/toolOutput"; + +/** A tool's output: a preview, "Show all (N lines)" / "Show less" when it is + * longer (bounded, see lib/toolOutput), and Copy for the whole of it. Copy + * sits in the box's corner as on code blocks, shown on hover or focus so a + * column of short outputs stays quiet. `name` says whose output it is + * ("npm test") in the buttons' accessible names. Memoized: an open timeline + * re-renders while a task runs. */ +export const ToolOutput = memo(function ToolOutput({ + text, + name, + className, +}: { + text: string; + name: string; + /** For the `
` (each place styles its own box). */
+  className?: string;
+}) {
+  const [expanded, setExpanded] = useState(false);
+  const view = useMemo(() => outputView(text, expanded), [text, expanded]);
+  const [copied, copy] = useCopyFeedback();
+  return (
+    
+ {/* The box scrolls: focusable, so the keyboard can scroll it too. */} +
+        {view.text}
+      
+ + {view.long && ( +
+ {view.hidden && {view.hidden}} + +
+ )} +
+ ); +}); diff --git a/ui/src/components/cards.tsx b/ui/src/components/cards.tsx index c43f4cea..b0998e6b 100644 --- a/ui/src/components/cards.tsx +++ b/ui/src/components/cards.tsx @@ -4,6 +4,7 @@ import type { SubagentRun } from "../lib/subagents"; import type { TaskMode } from "../lib/effort"; import { readableError } from "../lib/transport"; import { Markdown } from "./Markdown"; +import { ToolOutput } from "./ToolOutput"; /** Every row carries a React `key` that survives updates (lib/rowKeys). */ export type ChatItem = ( @@ -29,6 +30,8 @@ export type ChatItem = ( /** A follow-up ShadowCode wrote after a plan limit: "auto" when the * engine started it, "manual" when the user chose Continue on …. */ continued?: "auto" | "manual"; + /** When it was sent: its recording event's `ts` (seconds). */ + ts?: number; } | { kind: "note"; @@ -144,6 +147,8 @@ export type ChatItem = ( eventId?: number; taskId?: string; live?: boolean; + /** When it was given: its latest text event's `ts` (seconds). */ + ts?: number; } | { kind: "tool"; @@ -235,10 +240,11 @@ export const OpCard = memo(function OpCard({ {open && (
{item.fullOutput ? ( -
-              {item.fullOutput.slice(0, 4000)}
-              {item.fullOutput.length > 4000 ? "\n… (truncated)" : ""}
-            
+ ) : (

No output.

)} diff --git a/ui/src/components/shell/TranscriptRows.times.test.tsx b/ui/src/components/shell/TranscriptRows.times.test.tsx new file mode 100644 index 00000000..e7a95ad8 --- /dev/null +++ b/ui/src/components/shell/TranscriptRows.times.test.tsx @@ -0,0 +1,79 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import { createRef } from "react"; +import { TranscriptRows, type RowActions } from "./TranscriptRows"; +import type { ChatItem } from "../cards"; +import { messageTime } from "../../lib/messageTime"; + +vi.mock("../Markdown", () => ({ + Markdown: ({ children }: { children: string }) =>

{children}

, +})); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const actions = new Proxy({}, { get: () => vi.fn() }) as RowActions; + +/** Local wall-clock times, so the test holds in any time zone. */ +const at = (h: number, min: number) => + new Date(2026, 9, 12, h, min).getTime() / 1000; + +const scrollRef = createRef(); +const rows = (items: ChatItem[]) => ( +
+ +
+); +const view = (items: ChatItem[]) => render(rows(items)); + +it("prompts and answers show their time; a quick reply's stays quiet", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 9, 12, 16, 0)); + const now = Date.now() / 1000; + const { container } = view([ + { kind: "user", text: "Fix add", ts: at(14, 32), key: "u1" }, + { kind: "agent", text: "Fixed", ts: at(14, 33), key: "a1" }, + { kind: "user", text: "Thanks", ts: at(15, 10), key: "u2" }, + { kind: "agent", text: "No time recorded", key: "a2" }, + ]); + const times = [...container.querySelectorAll("time")]; + expect(times).toHaveLength(3); + const first = messageTime(at(14, 32), now)!; + expect(times[0].textContent).toBe(first.label); + expect(times[0].getAttribute("dateTime")).toBe(first.iso); + expect(times[0].getAttribute("title")).toBe(first.full); + expect(times[0].classList.contains("is-quiet")).toBe(false); + // A minute later: shown on hover only. + expect(times[1].classList.contains("is-quiet")).toBe(true); + expect(times[2].classList.contains("is-quiet")).toBe(false); + expect(times[2].textContent).toBe(messageTime(at(15, 10), now)!.label); + // The time sits outside the actions group. + expect(times[0].closest('[role="group"]')).toBeNull(); +}); + +it("a streaming answer shows its time once it is complete", () => { + const live: ChatItem = { + kind: "agent", + text: "Working", + ts: at(9, 0), + live: true, + key: "m", + }; + const { container, rerender } = view([live]); + expect(container.querySelector("time")).toBeNull(); + rerender(rows([{ ...live, live: false }])); + expect(container.querySelector("time")).toBeTruthy(); +}); diff --git a/ui/src/components/shell/TranscriptRows.tsx b/ui/src/components/shell/TranscriptRows.tsx index 33a78b96..b5975e34 100644 --- a/ui/src/components/shell/TranscriptRows.tsx +++ b/ui/src/components/shell/TranscriptRows.tsx @@ -18,6 +18,8 @@ import { SpendLimitCard } from "../SpendLimitCard"; import { StuckCard, type StuckAction } from "../StuckCard"; import { Markdown } from "../Markdown"; import { CopyButton, UserMessage } from "../MessageActions"; +import { MessageTime } from "../MessageTime"; +import { quietTimes } from "../../lib/messageTime"; import { SubagentCard } from "../SubagentCard"; import { FailureHelp } from "../FailureHelp"; import { @@ -166,10 +168,12 @@ type AgentItem = Extract; function AgentAnswer({ item, forkDisabled, + quietTime, actions, }: { item: AgentItem; forkDisabled: boolean; + quietTime: boolean; actions: RowActions; }) { const ask = useAskOpen(); @@ -198,29 +202,36 @@ function AgentAnswer({ /> )} {!item.live && ( -
- - {item.taskId && ( - + +
+ - )} - {item.eventId && ( - - )} + {item.taskId && ( + + )} + {item.eventId && ( + + )} +
)} {ask.open && item.taskId && ( @@ -243,6 +254,7 @@ const TranscriptRow = memo(function TranscriptRow({ fallback, locked, forkDisabled, + quietTime, actions, }: Omit & { rowKey: string; @@ -250,6 +262,8 @@ const TranscriptRow = memo(function TranscriptRow({ fallback: Fallback | null; locked: boolean; forkDisabled: boolean; + /** Its time shows on hover only (lib/messageTime `quietTimes`). */ + quietTime: boolean; actions: RowActions; }) { let node: ReactNode = null; @@ -360,6 +374,7 @@ const TranscriptRow = memo(function TranscriptRow({ + ); if (stranded) node = ( @@ -458,6 +478,17 @@ export function TranscriptRows({ if (row.item.taskId) last.set(row.item.taskId, row.key); return new Set(last.values()); }, [rows]); + // The first message on screen always shows its time. + const quiet = useMemo( + () => + quietTimes( + rows + .slice(start) + .filter((row) => !row.queued) + .map((row) => row.item), + ), + [rows, start], + ); return ( <> {start > 0 && ( @@ -505,6 +536,7 @@ export function TranscriptRows({ ? forkDisabled : false } + quietTime={quiet.has(row.item)} actions={actions} /> {row.item.taskId && lastOfTask.has(row.key) && ( diff --git a/ui/src/hooks/useConversation.ts b/ui/src/hooks/useConversation.ts index 7a803b65..44391304 100644 --- a/ui/src/hooks/useConversation.ts +++ b/ui/src/hooks/useConversation.ts @@ -19,6 +19,7 @@ import { type Transcript, } from "../lib/transcript"; import { jobEvents } from "../lib/jobEvents"; +import { eventTime } from "../lib/messageTime"; import { isActive } from "../lib/jobs"; export { isActive } from "../lib/jobs"; @@ -89,6 +90,7 @@ export function useConversation(onComplete: (done?: Job) => void) { kind: "agent", text: active.summary, who: outcomeLabel(active.status), + ts: eventTime(active.finished_at), key: `outcome:${active.id}`, }); } @@ -153,6 +155,7 @@ export function useConversation(onComplete: (done?: Job) => void) { kind: "agent", text: done.summary, who: outcomeLabel(done.status), + ts: eventTime(done.finished_at), key: `outcome:${done.id}`, }); return { diff --git a/ui/src/hooks/useCopyFeedback.ts b/ui/src/hooks/useCopyFeedback.ts new file mode 100644 index 00000000..1c5dbe12 --- /dev/null +++ b/ui/src/hooks/useCopyFeedback.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** How long a copy button says "Copied". */ +export const COPIED_MS = 1800; + +/** Copies text and says so on the button for a moment ("Copied"), the way + * code blocks and tool output do. A copy that fails leaves the button as it + * was. */ +export function useCopyFeedback(): [ + copied: boolean, + copy: (text: string) => Promise, +] { + const [copied, setCopied] = useState(false); + const timer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + const copy = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), COPIED_MS); + } catch { + setCopied(false); + } + }, []); + return [copied, copy]; +} diff --git a/ui/src/hooks/useWindow.title.test.ts b/ui/src/hooks/useWindow.title.test.ts new file mode 100644 index 00000000..88a2bfd3 --- /dev/null +++ b/ui/src/hooks/useWindow.title.test.ts @@ -0,0 +1,34 @@ +import { renderHook } from "@testing-library/react"; +import { expect, it } from "vitest"; +import { useWindowTitle, windowTitle } from "./useWindow"; + +it("names the open conversation, with a dot while a task runs", () => { + expect(windowTitle("Fix the add function", false)).toBe( + "Fix the add function — ShadowCode", + ); + expect(windowTitle("Fix the add function", true)).toBe( + "● Fix the add function — ShadowCode", + ); + expect(windowTitle(undefined, false)).toBe("ShadowCode"); + expect(windowTitle(" ", true)).toBe("● ShadowCode"); + // One line, however the title was typed. + expect(windowTitle(" Two\nlines ", false)).toBe("Two lines — ShadowCode"); +}); + +it("keeps the document title in step", () => { + const { rerender } = renderHook( + ({ title, busy }: { title: string | undefined; busy: boolean }) => + useWindowTitle(title, busy), + { + initialProps: { + title: "Release notes" as string | undefined, + busy: true, + }, + }, + ); + expect(document.title).toBe("● Release notes — ShadowCode"); + rerender({ title: "Release notes", busy: false }); + expect(document.title).toBe("Release notes — ShadowCode"); + rerender({ title: undefined, busy: false }); + expect(document.title).toBe("ShadowCode"); +}); diff --git a/ui/src/hooks/useWindow.ts b/ui/src/hooks/useWindow.ts index 29b27863..6b18e691 100644 --- a/ui/src/hooks/useWindow.ts +++ b/ui/src/hooks/useWindow.ts @@ -76,3 +76,25 @@ export function useSidebar() { }, []); return [sidebar, setSidebar] as const; } + +/** " — ShadowCode", or plain "ShadowCode" with no + * conversation open; "● " in front while a task runs. */ +export function windowTitle( + conversation: string | undefined, + busy: boolean, +): string { + const name = conversation?.replace(/\s+/g, " ").trim(); + return `${busy ? "● " : ""}${name ? `${name} — ` : ""}ShadowCode`; +} + +/** Keeps the document title in step with the open conversation, so a + * browser tab (remote access) or a shell that passes the title on names it. */ +export function useWindowTitle( + conversation: string | undefined, + busy: boolean, +) { + const title = windowTitle(conversation, busy); + useEffect(() => { + document.title = title; + }, [title]); +} diff --git a/ui/src/lib/messageTime.test.ts b/ui/src/lib/messageTime.test.ts new file mode 100644 index 00000000..34064849 --- /dev/null +++ b/ui/src/lib/messageTime.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import type { ChatItem } from "../components/cards"; +import { + QUIET_GAP_SECONDS, + eventTime, + messageTime, + quietTimes, +} from "./messageTime"; + +/** Local wall-clock times, so the tests hold in any time zone. */ +const at = (y: number, m: number, d: number, h: number, min: number) => + new Date(y, m - 1, d, h, min).getTime() / 1000; + +describe("message times", () => { + const now = at(2026, 10, 12, 16, 5); + const label = (ts: number, locale = "en-GB") => + messageTime(ts, now, locale)?.label; + + it("says the time today, Yesterday, the date this year, the year before", () => { + expect(label(at(2026, 10, 12, 14, 32))).toBe("14:32"); + expect(label(at(2026, 10, 12, 0, 1))).toBe("00:01"); + expect(label(at(2026, 10, 11, 14, 32))).toBe("Yesterday 14:32"); + expect(label(at(2026, 10, 11, 23, 59))).toBe("Yesterday 23:59"); + expect(label(at(2026, 10, 3, 9, 15))).toBe("3 Oct, 09:15"); + expect(label(at(2026, 1, 1, 9, 15))).toBe("1 Jan, 09:15"); + expect(label(at(2025, 12, 31, 23, 0))).toBe("31 Dec 2025"); + }); + + it("follows the viewer's locale", () => { + expect(label(at(2026, 10, 12, 14, 32), "en-US")).toMatch(/^2:32\sPM$/); + expect(label(at(2026, 10, 11, 9, 5), "en-US")).toMatch( + /^Yesterday 9:05\sAM$/, + ); + expect(label(at(2026, 10, 3, 9, 15), "en-US")).toMatch(/^Oct 3, 9:15\sAM$/); + expect(label(at(2026, 10, 3, 9, 15), "de-DE")).toBe("3. Okt., 09:15"); + }); + + it("gives the full date and time for the tooltip and a machine time", () => { + const ts = at(2026, 10, 11, 14, 32); + const time = messageTime(ts, now, "en-GB"); + expect(time?.full).toMatch(/^Sunday,? 11 October 2026.* 14:32$/); + expect(time?.iso).toBe(new Date(ts * 1000).toISOString()); + }); + + it("a later day (a clock set wrong) shows its date, not a bare time", () => { + expect(label(at(2026, 10, 13, 8, 0))).toBe("13 Oct, 08:00"); + }); + + it("words follow the clock: today's time is Yesterday after midnight", () => { + const ts = at(2026, 10, 12, 23, 50); + expect(messageTime(ts, at(2026, 10, 12, 23, 55), "en-GB")?.label).toBe( + "23:50", + ); + expect(messageTime(ts, at(2026, 10, 13, 0, 5), "en-GB")?.label).toBe( + "Yesterday 23:50", + ); + }); + + it("never makes up a time", () => { + expect(messageTime(0, now)).toBeNull(); + expect(messageTime(Number.NaN, now)).toBeNull(); + expect(eventTime(undefined)).toBeUndefined(); + expect(eventTime("1790000000")).toBeUndefined(); + expect(eventTime(-5)).toBeUndefined(); + expect(eventTime(1_790_000_000.25)).toBe(1_790_000_000.25); + }); +}); + +describe("quiet times", () => { + const user = (ts?: number): ChatItem => ({ kind: "user", text: "Q", ts }); + const agent = (ts?: number): ChatItem => ({ kind: "agent", text: "A", ts }); + const note: ChatItem = { kind: "note", text: "Using Codex" }; + const start = at(2026, 10, 12, 14, 0); + + it("a quick exchange shows its time once", () => { + const items = [user(start), note, agent(start + 40), user(start + 120)]; + const quiet = quietTimes(items); + expect(quiet.has(items[0])).toBe(false); + expect(quiet.has(items[2])).toBe(true); + expect(quiet.has(items[3])).toBe(true); + }); + + it("shows the time again a few minutes after the last one shown", () => { + const step = QUIET_GAP_SECONDS - 60; + const items = [0, 1, 2, 3].map((n) => agent(start + n * step)); + const quiet = quietTimes(items); + expect(items.map((item) => quiet.has(item))).toEqual([ + false, + true, + false, + true, + ]); + }); + + it("a new day, an earlier time or a message without one is never quiet", () => { + const late = at(2026, 10, 12, 23, 58); + const items = [ + user(late), + agent(at(2026, 10, 13, 0, 1)), + user(late), + agent(undefined), + agent(late + 30), + ]; + const quiet = quietTimes(items); + expect(quiet.has(items[1])).toBe(false); + expect(quiet.has(items[2])).toBe(false); + expect(quiet.has(items[3])).toBe(false); + expect(quiet.has(items[4])).toBe(true); + }); +}); diff --git a/ui/src/lib/messageTime.ts b/ui/src/lib/messageTime.ts new file mode 100644 index 00000000..eccf9613 --- /dev/null +++ b/ui/src/lib/messageTime.ts @@ -0,0 +1,113 @@ +import type { ChatItem } from "../components/cards"; + +/** A message within this many seconds of the one before it (on the same + * day) keeps its time quiet: it shows on hover or focus, not all the time. */ +export const QUIET_GAP_SECONDS = 5 * 60; + +/** An event's `ts` (seconds since the epoch, as the engine records it), or + * nothing when it is missing or unusable. A message never shows a made-up + * time. */ +export function eventTime(ts: unknown): number | undefined { + return typeof ts === "number" && Number.isFinite(ts) && ts > 0 + ? ts + : undefined; +} + +export type MessageTimeText = { + /** What the transcript shows: "14:32", "Yesterday 14:32", "3 Oct, 14:32", + * or "3 Oct 2025" for another year. */ + label: string; + /** The full local date and time, for the tooltip. */ + full: string; + /** Machine-readable, for `