From ff71bae62d22d72be1e7ba34fe2b543fedf06621 Mon Sep 17 00:00:00 2001 From: Noah Clarkson Date: Thu, 3 Sep 2026 00:27:00 +1200 Subject: [PATCH 1/7] feat(ai): overhaul the AI subsystem Anthropic had never once produced a commit message: its tool loop sent `messages: []` on the first iteration, which the Messages API rejects with a 400, and `use_tools` defaults to true so that was the default path. The shipped default model was a retired id that appeared in no picker, so a fresh install rendered the Model row with nothing selected at all. The model pill row had no `flex_wrap()`, so the default window could not render the default provider's own model list. `get_file_content` correctly rejected `../.env` and then happily read `.env`. All four are fixed here, along with the structural problems underneath them. Correctness - Seed an opening user turn for every provider, guarded by a test asserting no provider sends an empty conversation on iteration 1. - Replace string provider dispatch with an `AiProvider` enum owning endpoint, auth shape and default model; unknown ids in settings.json are reported through `load_warnings` instead of failing at click time. - Make every Gemini response field optional and iterate all parts, so a safety block, a `MAX_TOKENS` stop, a leading thought part and parallel function calls stop collapsing into one opaque parse error. - Bail on a `tool_calls` finish with an empty array rather than burning iterations to reach a generic message. - Cap response bodies at 8 MB, move the Gemini key from the query string to `x-goog-api-key`, and use `from_utf8_lossy` in the diff walker so non-UTF-8 lines stop vanishing from what the model reasons about. Threading and lifecycle - Run the whole provider dispatch, tool execution and JSON parsing on the background executor, reporting tool progress over an mpsc channel that a small foreground task drains. `execute_tool` spawns `git` and walks directories synchronously; none of that belongs on the render thread. - Give every request a 60s deadline and hold its `Task`, so a stalled provider no longer leaves the spinner running until the app is restarted, and a cancel button can drop it. - Carry a `GenerationId { sequence, repo_path }` on every event and route by repo path rather than `active_tab`, so a message generated for one tab cannot land in another's commit box. - Stamp the cooldown on completion rather than dispatch, and report it as info without clearing an in-flight generation's spinner. - Skip unchanged keychain writes, debounce the rest by 400ms, and resolve all providers' keys in one pass. - Retry 429/5xx honouring `Retry-After`, set an Anthropic cache breakpoint after the system prompt, lower the diff cap to 40 KB, and give tool output a per-generation budget. Security - Deny `.git/`, a credentials denylist, git-ignored paths and non-UTF-8 content before `get_file_content` reads anything, and re-check the canonicalised path so an in-repo symlink cannot reach `.git/config`. Per-provider credentials - One keychain slot per provider (`ai/provider/{id}`), migrated from the single `ai/default` slot, with per-provider model pins so switching provider no longer destroys the previous provider's key or its model choice. Features - Live model catalogue with stale-while-revalidate caching, a bundled fallback, and a generation guard; the picker labels which source it is showing so "three weeks old" is distinguishable from "shipped with the app". - OpenRouter, via the existing OpenAI-compatible path widened with an endpoint struct; the tool-support 404 gets an actionable message. - `base_url_override` for the OpenAI-compatible family, validated, with an inline warning naming the host the key will be sent to. UI - Provider accordion replacing six flat cards, each row owning its key field, connection status and model pin, with a connection test that can tell a working key from a typo. - `flex_wrap()` on `pill_group` and `overflow_hidden` on `setting_card`. - The AI button states its own reason when it cannot be used, stays reachable when the fix is one click away, and shows the shortcut that was registered all along. - Tool progress routed to the panel chip, cancel, regenerate with a style override, and undo of an AI overwrite. - Errors stop auto-dismissing at 3s and can carry an action; per-level toast durations replace the single hardcoded one. - Delete the Save button: text inputs now flush on Enter and on blur, so the same value no longer persists or vanishes depending on how it arrived. - `tab_index` across the settings page, which previously had none. Removals - `last_result`/`last_error` had no callers; `uuid` was one call site whose id was never transmitted; `http_client` was never referenced by path. - `fuzzy_score` is now one shared implementation rather than three. Not included: streaming tokens into the summary field. It needs per-provider SSE parsing including tool-call deltas across three wire formats, with no way to exercise it offline; the progress trace covers the feedback gap meanwhile. The recommended model ids come from the plan's research and have not been verified against a live API. A wrong id is now visible and correctable in the picker rather than silently unselectable, but each still wants one real request to confirm. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 4 +- crates/rgitui_ai/Cargo.toml | 4 +- crates/rgitui_ai/src/catalog.rs | 1320 ++++++++++++ .../rgitui_ai/src/catalog/static_catalog.rs | 357 ++++ crates/rgitui_ai/src/http.rs | 144 ++ crates/rgitui_ai/src/lib.rs | 1847 +++++++++-------- crates/rgitui_ai/src/prompt.rs | 355 ++++ crates/rgitui_ai/src/provider.rs | 524 +++++ crates/rgitui_ai/src/tools.rs | 340 ++- crates/rgitui_git/src/project/diff.rs | 12 +- crates/rgitui_git/src/project/mod.rs | 2 +- crates/rgitui_graph/src/lib.rs | 1 + crates/rgitui_settings/src/lib.rs | 650 +++++- crates/rgitui_ui/src/fuzzy.rs | 98 + crates/rgitui_ui/src/lib.rs | 8 + crates/rgitui_ui/src/picker.rs | 627 ++++++ crates/rgitui_ui/src/select.rs | 509 +++++ crates/rgitui_ui/src/status_pill.rs | 207 ++ crates/rgitui_ui/src/text_input.rs | 16 + crates/rgitui_ui/src/toast.rs | 40 + crates/rgitui_workspace/src/branch_dialog.rs | 1 + .../rgitui_workspace/src/command_palette.rs | 154 +- crates/rgitui_workspace/src/commit_panel.rs | 651 +++++- crates/rgitui_workspace/src/detail_panel.rs | 8 +- crates/rgitui_workspace/src/issues_panel.rs | 1 + .../rgitui_workspace/src/keymap/registry.rs | 4 +- crates/rgitui_workspace/src/rename_dialog.rs | 1 + crates/rgitui_workspace/src/repo_opener.rs | 1 + crates/rgitui_workspace/src/search_panel.rs | 1 + .../src/settings_window/ai_section.rs | 1492 +++++++++++++ .../src/settings_window/mod.rs | 1 + .../src/settings_window/view.rs | 1067 ++++++---- .../src/settings_window/window.rs | 18 +- .../src/stash_branch_dialog.rs | 1 + .../rgitui_workspace/src/stash_save_dialog.rs | 1 + crates/rgitui_workspace/src/tag_dialog.rs | 1 + crates/rgitui_workspace/src/toast.rs | 162 +- .../src/workspace/commands.rs | 3 + .../rgitui_workspace/src/workspace/events.rs | 247 ++- crates/rgitui_workspace/src/workspace/mod.rs | 39 + crates/rgitui_workspace/src/workspace/tabs.rs | 6 + .../rgitui_workspace/src/worktree_dialog.rs | 2 + 42 files changed, 9240 insertions(+), 1687 deletions(-) create mode 100644 crates/rgitui_ai/src/catalog.rs create mode 100644 crates/rgitui_ai/src/catalog/static_catalog.rs create mode 100644 crates/rgitui_ai/src/http.rs create mode 100644 crates/rgitui_ai/src/prompt.rs create mode 100644 crates/rgitui_ai/src/provider.rs create mode 100644 crates/rgitui_ui/src/fuzzy.rs create mode 100644 crates/rgitui_ui/src/picker.rs create mode 100644 crates/rgitui_ui/src/select.rs create mode 100644 crates/rgitui_ui/src/status_pill.rs create mode 100644 crates/rgitui_workspace/src/settings_window/ai_section.rs diff --git a/Cargo.lock b/Cargo.lock index 662fa5e7..c35812cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5548,12 +5548,12 @@ dependencies = [ "anyhow", "futures", "gpui", - "http_client", + "log", + "rgitui_git", "rgitui_settings", "serde", "serde_json", "tempfile", - "uuid", ] [[package]] diff --git a/crates/rgitui_ai/Cargo.toml b/crates/rgitui_ai/Cargo.toml index e44a2878..9b79e5b0 100644 --- a/crates/rgitui_ai/Cargo.toml +++ b/crates/rgitui_ai/Cargo.toml @@ -9,13 +9,13 @@ workspace = true [dependencies] gpui.workspace = true -http_client.workspace = true rgitui_settings.workspace = true serde.workspace = true serde_json.workspace = true anyhow.workspace = true futures.workspace = true -uuid.workspace = true +rgitui_git.workspace = true +log.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/rgitui_ai/src/catalog.rs b/crates/rgitui_ai/src/catalog.rs new file mode 100644 index 00000000..ea6f3ca1 --- /dev/null +++ b/crates/rgitui_ai/src/catalog.rs @@ -0,0 +1,1320 @@ +//! Live, auto-updating model catalogue. +//! +//! Replaces the hardcoded model list that used to live inside a render +//! function in the settings view, where it could not be extended by the user, +//! could not be tested, and went stale silently. +//! +//! Everything that touches the network or the filesystem is an `async fn` or a +//! plain function taking explicit inputs, and every decision the picker makes +//! — filtering, ranking, freshness, pinned-model classification — is a pure +//! function with tests, per the convention in `CLAUDE.md`. +//! +//! Threading: [`fetch_models`] runs the round-trip *and* the JSON parse, so +//! callers must invoke it from `cx.background_executor().spawn(...)`. The +//! unfiltered OpenRouter payload is roughly 700 KB and must never be +//! deserialised on the render thread. + +pub mod static_catalog; + +use anyhow::{Context as _, Result}; +use gpui::http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Request}; +use rgitui_settings::{cache_dir, AiProvider}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +pub use static_catalog::{bundled_catalog, enrich_from_static}; + +use crate::http::{read_response_body, CATALOG_REQUEST_TIMEOUT}; + +/// Whether a model can be given tools. +/// +/// `Unknown` is load-bearing: OpenAI and DeepSeek genuinely do not report it, +/// and rendering that honestly beats a confidently wrong badge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ToolSupport { + Supported, + Unsupported, + Unknown, +} + +impl ToolSupport { + /// The badge text for the picker, or `None` when nothing honest can be + /// said. + pub fn badge(self) -> Option<&'static str> { + match self { + ToolSupport::Supported => Some("Tools"), + ToolSupport::Unsupported => Some("No tools"), + ToolSupport::Unknown => None, + } + } +} + +/// One selectable model. Every field is something the picker renders or +/// filters on. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModelInfo { + /// The exact string sent as the request's `model` field. + pub id: String, + pub display_name: String, + pub context_length: Option, + pub max_output_tokens: Option, + /// USD per million tokens. Only OpenRouter reports pricing; the rest leave + /// these `None` and the picker renders a blank column, which reads as + /// "unknown" correctly. + pub prompt_price_per_mtok: Option, + pub completion_price_per_mtok: Option, + pub tool_support: ToolSupport, + /// Emits text, as opposed to image/audio/embeddings. + pub emits_text: bool, + /// A `:free`/`:batch` variant or a `…-latest` alias rather than a distinct + /// model. Hidden by default. + pub is_variant: bool, + pub created: Option, +} + +impl ModelInfo { + /// The one-line summary shown under the model field: + /// `1M ctx · $0.25/$1.50 per Mtok · Tools`. + pub fn summary_line(&self) -> String { + let mut parts: Vec = Vec::new(); + if let Some(ctx) = self.context_length { + parts.push(format!("{} ctx", format_context(ctx))); + } + if let (Some(prompt), Some(completion)) = + (self.prompt_price_per_mtok, self.completion_price_per_mtok) + { + parts.push(format!( + "${:.2}/${:.2} per Mtok", + prompt.max(0.0), + completion.max(0.0) + )); + } + if let Some(badge) = self.tool_support.badge() { + parts.push(badge.to_string()); + } + parts.join(" · ") + } + + /// The compact right-hand column of a picker row: `1M $0.25/$1.50`. + pub fn trailing_label(&self) -> String { + let ctx = self.context_length.map(format_context).unwrap_or_default(); + match (self.prompt_price_per_mtok, self.completion_price_per_mtok) { + (Some(prompt), Some(completion)) => format!( + "{ctx} ${:.2}/${:.2}", + prompt.max(0.0), + completion.max(0.0) + ) + .trim_start() + .to_string(), + _ => ctx, + } + } + + pub fn is_free(&self) -> bool { + self.prompt_price_per_mtok == Some(0.0) + } +} + +/// Render a context window the way the provider docs do: `1M`, `128K`, `4096`. +pub fn format_context(tokens: u32) -> String { + if tokens >= 1_000_000 { + let millions = tokens as f64 / 1_000_000.0; + // A round million reads as `1M`; anything else keeps two decimals, so + // 1,048,576 renders as `1.05M` rather than being rounded away. + if (millions - millions.round()).abs() < 0.005 { + format!("{}M", millions.round() as u64) + } else { + format!("{millions:.2}M") + } + } else if tokens >= 1_000 { + format!("{}K", tokens / 1_000) + } else { + tokens.to_string() + } +} + +// ============================================================================ +// Cache envelope +// ============================================================================ + +/// Bump to invalidate stale cache files after a [`ModelInfo`] shape change. +pub const CATALOG_SCHEMA: u32 = 1; +const CATALOG_TTL_SECS: i64 = 24 * 60 * 60; +const CATALOG_STALE_SECS: i64 = 30 * 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedCatalog { + pub schema: u32, + /// Unix seconds. + pub fetched_at: i64, + pub models: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CatalogFreshness { + Fresh, + Stale, + Expired, +} + +/// Pure — no clock, no filesystem, so the boundaries are directly testable. +pub fn freshness(fetched_at: i64, now: i64) -> CatalogFreshness { + let age = now.saturating_sub(fetched_at); + // A timestamp from the future means the clock moved backwards; treat it as + // fresh rather than re-fetching in a loop. + if age < 0 || age < CATALOG_TTL_SECS { + CatalogFreshness::Fresh + } else if age < CATALOG_STALE_SECS { + CatalogFreshness::Stale + } else { + CatalogFreshness::Expired + } +} + +/// Where a rendered catalogue came from. The picker labels it so the user can +/// tell "three weeks old" from "shipped with the app". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CatalogSource { + Live, + Cache { fetched_at: i64 }, + Bundled, +} + +/// Live > cache > bundled. +pub fn resolve_catalog( + provider: AiProvider, + cached: Option, +) -> (Vec, CatalogSource) { + match cached { + Some(catalog) if catalog.schema == CATALOG_SCHEMA && !catalog.models.is_empty() => { + let fetched_at = catalog.fetched_at; + (catalog.models, CatalogSource::Cache { fetched_at }) + } + _ => (bundled_catalog(provider), CatalogSource::Bundled), + } +} + +pub fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn catalog_path(provider: AiProvider) -> PathBuf { + cache_dir() + .join("models") + .join(format!("{}.json", provider.id())) +} + +/// Read a provider's cached catalogue. A few KB of JSON — cheap enough to read +/// synchronously when the picker opens. +pub fn read_cached(provider: AiProvider) -> Option { + let json = std::fs::read_to_string(catalog_path(provider)).ok()?; + let catalog: CachedCatalog = serde_json::from_str(&json).ok()?; + (catalog.schema == CATALOG_SCHEMA).then_some(catalog) +} + +/// Write a provider's catalogue, temp-file-then-rename so a crash mid-write +/// cannot leave a truncated file that then fails to parse forever. +pub fn write_cached(provider: AiProvider, catalog: &CachedCatalog) -> Result<()> { + let path = catalog_path(provider); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string(catalog)?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &json)?; + if std::fs::rename(&tmp, &path).is_err() { + std::fs::write(&path, &json)?; + let _ = std::fs::remove_file(&tmp); + } + Ok(()) +} + +// ============================================================================ +// Fetching +// ============================================================================ + +/// Whether fetching this provider's catalogue requires the user's API key. +/// Only OpenRouter's is public. +pub fn catalog_needs_key(provider: AiProvider) -> bool { + !matches!(provider, AiProvider::OpenRouter) +} + +/// The recommended OpenRouter query: tool-capable text models sorted by coding +/// ability, which is exactly the axis that matters for a commit-message +/// generator. 145 KB rather than the 697 KB full dump. +const OPENROUTER_MODELS_URL: &str = "https://openrouter.ai/api/v1/models\ + ?supported_parameters=tools&output_modalities=text&sort=coding-high-to-low&limit=60"; + +/// The unfiltered OpenRouter catalogue, behind "load all" in the picker. +const OPENROUTER_ALL_MODELS_URL: &str = "https://openrouter.ai/api/v1/models"; + +/// Fetch a provider's model list. Runs the round-trip **and** the parse — call +/// it from a background task. +pub async fn fetch_models( + provider: AiProvider, + client: &Arc, + api_key: Option<&str>, +) -> Result> { + fetch_models_inner(provider, client, api_key, false).await +} + +/// As [`fetch_models`], but asks OpenRouter for its entire catalogue rather +/// than the recommended 60-model slice. +pub async fn fetch_all_models( + provider: AiProvider, + client: &Arc, + api_key: Option<&str>, +) -> Result> { + fetch_models_inner(provider, client, api_key, true).await +} + +async fn fetch_models_inner( + provider: AiProvider, + client: &Arc, + api_key: Option<&str>, + load_all: bool, +) -> Result> { + if catalog_needs_key(provider) && api_key.map(str::trim).unwrap_or("").is_empty() { + anyhow::bail!( + "{} needs an API key before its model list can be loaded.", + provider.display_name() + ); + } + + let (url, mut builder) = match provider { + AiProvider::OpenRouter => { + let url = if load_all { + OPENROUTER_ALL_MODELS_URL + } else { + OPENROUTER_MODELS_URL + }; + (url.to_string(), Request::builder()) + } + AiProvider::Anthropic => ( + // The default limit is 20; without this the list renders silently + // truncated. + "https://api.anthropic.com/v1/models?limit=100".to_string(), + Request::builder().header("anthropic-version", crate::provider::ANTHROPIC_VERSION), + ), + AiProvider::Gemini => ( + "https://generativelanguage.googleapis.com/v1beta/models?pageSize=200".to_string(), + Request::builder(), + ), + AiProvider::OpenAi => ( + "https://api.openai.com/v1/models".to_string(), + Request::builder(), + ), + AiProvider::DeepSeek => ( + "https://api.deepseek.com/models".to_string(), + Request::builder(), + ), + }; + + if let Some(key) = api_key.map(str::trim).filter(|key| !key.is_empty()) { + builder = match crate::provider::auth_style(provider) { + crate::provider::AuthStyle::Bearer => { + builder.header("Authorization", format!("Bearer {key}")) + } + crate::provider::AuthStyle::AnthropicHeader => builder.header("x-api-key", key), + crate::provider::AuthStyle::GoogleHeader => builder.header("x-goog-api-key", key), + }; + } + + let request = builder + .method(Method::GET) + .uri(&url) + .timeout(CATALOG_REQUEST_TIMEOUT) + .body(AsyncBody::from(Vec::new())) + .with_context(|| format!("Failed to build the {} model-list request", provider))?; + + let mut response = client + .send(request) + .await + .with_context(|| format!("Couldn't reach {}", provider.default_host()))?; + + let status = response.status(); + let body = read_response_body(&mut response).await?; + if !status.is_success() { + anyhow::bail!( + "{} rejected the model-list request ({}).", + provider.display_name(), + status + ); + } + + let json: serde_json::Value = serde_json::from_slice(&body) + .with_context(|| format!("Couldn't read {}'s model list", provider.display_name()))?; + + let mut models = parse_models(provider, &json); + enrich_from_static(provider, &mut models); + if models.is_empty() { + anyhow::bail!("{} returned no usable models.", provider.display_name()); + } + Ok(models) +} + +/// Parse a provider's `/models` payload. Pure, so the real captured fixtures +/// in the test module exercise exactly what the network path does. +pub fn parse_models(provider: AiProvider, json: &serde_json::Value) -> Vec { + match provider { + AiProvider::OpenRouter => parse_openrouter(json), + AiProvider::Anthropic => parse_anthropic(json), + AiProvider::Gemini => parse_gemini(json), + AiProvider::OpenAi => parse_openai(json), + AiProvider::DeepSeek => parse_deepseek(json), + } +} + +/// OpenRouter reports `pricing.*` as USD-per-token, encoded as JSON *strings*. +/// Five models report negative prices (BYOK rebate rows), so nothing here may +/// assume a non-negative value. +fn price_per_mtok(value: Option<&serde_json::Value>) -> Option { + let raw = value?.as_str()?; + let per_token: f64 = raw.parse().ok()?; + Some(per_token * 1_000_000.0) +} + +/// Whether an OpenRouter slug names a variant rather than a distinct model. +pub fn is_openrouter_variant(id: &str, alias_target: Option<&str>) -> bool { + alias_target.is_some() + || id.contains(":free") + || id.contains(":batch") + || id.contains(":extended") + || id.ends_with("-latest") +} + +fn parse_openrouter(json: &serde_json::Value) -> Vec { + let Some(items) = json["data"].as_array() else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let id = item["id"].as_str()?.to_string(); + let supported: Vec<&str> = item["supported_parameters"] + .as_array() + .map(|values| values.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + let output_modalities: Vec<&str> = item["architecture"]["output_modalities"] + .as_array() + .map(|values| values.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + let emits_text = output_modalities.is_empty() || output_modalities.contains(&"text"); + + let context_length = item["context_length"] + .as_u64() + .or_else(|| item["top_provider"]["context_length"].as_u64()) + .map(|value| value as u32); + + Some(ModelInfo { + display_name: item["name"].as_str().unwrap_or(&id).to_string(), + context_length, + max_output_tokens: item["top_provider"]["max_completion_tokens"] + .as_u64() + .map(|value| value as u32), + prompt_price_per_mtok: price_per_mtok(item["pricing"].get("prompt")), + completion_price_per_mtok: price_per_mtok(item["pricing"].get("completion")), + tool_support: if supported.contains(&"tools") { + ToolSupport::Supported + } else { + ToolSupport::Unsupported + }, + emits_text, + is_variant: is_openrouter_variant(&id, item["alias_target"].as_str()), + created: item["created"].as_i64(), + id, + }) + }) + .collect() +} + +fn parse_anthropic(json: &serde_json::Value) -> Vec { + let Some(items) = json["data"].as_array() else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let id = item["id"].as_str()?.to_string(); + let supports_tools = item["capabilities"]["tool_use"].as_bool(); + Some(ModelInfo { + display_name: item["display_name"].as_str().unwrap_or(&id).to_string(), + context_length: item["max_input_tokens"].as_u64().map(|v| v as u32), + max_output_tokens: item["max_tokens"] + .as_u64() + .or_else(|| item["max_output_tokens"].as_u64()) + .map(|v| v as u32), + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + tool_support: match supports_tools { + Some(true) => ToolSupport::Supported, + Some(false) => ToolSupport::Unsupported, + // Every current Claude model takes tools, but say so only + // when the API says so. + None => ToolSupport::Unknown, + }, + emits_text: true, + is_variant: false, + created: None, + id, + }) + }) + .collect() +} + +fn parse_gemini(json: &serde_json::Value) -> Vec { + let Some(items) = json["models"].as_array() else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + // `models/gemini-3.1-flash-lite` -> `gemini-3.1-flash-lite`. + let raw = item["name"].as_str()?; + let id = raw.strip_prefix("models/").unwrap_or(raw).to_string(); + + // Gemini has no function-calling flag, so filter on generation + // methods instead: this is what drops embedding and TTS models. + let methods: Vec<&str> = item["supportedGenerationMethods"] + .as_array() + .map(|values| values.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + if !methods.is_empty() && !methods.contains(&"generateContent") { + return None; + } + + Some(ModelInfo { + display_name: item["displayName"].as_str().unwrap_or(&id).to_string(), + context_length: item["inputTokenLimit"].as_u64().map(|v| v as u32), + max_output_tokens: item["outputTokenLimit"].as_u64().map(|v| v as u32), + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + // Not advertised — say nothing rather than guess. + tool_support: ToolSupport::Unknown, + emits_text: true, + is_variant: id.ends_with("-latest"), + created: None, + id, + }) + }) + .collect() +} + +/// OpenAI's `/models` returns embeddings, TTS, whisper, image and fine-tune +/// models alongside chat models, with no field distinguishing them. +const OPENAI_NON_CHAT_PREFIXES: &[&str] = &[ + "text-embedding-", + "dall-e", + "whisper-", + "tts-", + "omni-moderation-", + "text-moderation-", + "gpt-image-", + "sora-", + "babbage-", + "davinci-", + "codex-mini", +]; + +fn parse_openai(json: &serde_json::Value) -> Vec { + let Some(items) = json["data"].as_array() else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let id = item["id"].as_str()?.to_string(); + if OPENAI_NON_CHAT_PREFIXES + .iter() + .any(|prefix| id.starts_with(prefix)) + { + return None; + } + // The one gift of this endpoint: it flags models with a scheduled + // shutdown, which is exactly the set that produces an opaque 404 + // at generation time. + if item["shutdown_date"].is_string() { + return None; + } + // The o-series 400s against the body this app builds — it needs + // `max_completion_tokens` and rejects a non-default temperature — + // so offering it would be offering a guaranteed failure. + if is_openai_o_series(&id) { + return None; + } + Some(ModelInfo { + display_name: id.clone(), + context_length: None, + max_output_tokens: None, + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + tool_support: ToolSupport::Unknown, + emits_text: true, + is_variant: id.ends_with("-latest"), + created: item["created"].as_i64(), + id, + }) + }) + .collect() +} + +/// `o1`, `o3`, `o4-mini`, … — but not `openai/…` or anything else that merely +/// starts with the letter. +pub fn is_openai_o_series(id: &str) -> bool { + let mut chars = id.chars(); + if chars.next() != Some('o') { + return false; + } + let rest: String = chars.collect(); + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() { + return false; + } + let tail = &rest[digits.len()..]; + tail.is_empty() || tail.starts_with('-') +} + +fn parse_deepseek(json: &serde_json::Value) -> Vec { + let Some(items) = json["data"].as_array() else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let id = item["id"].as_str()?.to_string(); + Some(ModelInfo { + display_name: id.clone(), + context_length: None, + max_output_tokens: None, + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + tool_support: ToolSupport::Unknown, + emits_text: true, + is_variant: false, + created: None, + id, + }) + }) + .collect() +} + +// ============================================================================ +// Filtering and ranking +// ============================================================================ + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModelFilter { + /// Defaults to mirroring `settings.ai.use_tools`. + pub tools_only: bool, + pub text_only: bool, + pub free_only: bool, + pub show_variants: bool, +} + +impl Default for ModelFilter { + fn default() -> Self { + Self { + tools_only: false, + text_only: true, + free_only: false, + show_variants: false, + } + } +} + +/// Filter a catalogue down to what the picker should offer. Pure, no GPUI. +/// +/// Ordering is preserved, because the server's own order is already the most +/// useful one there is — OpenRouter's is `coding-high-to-low`, which is +/// exactly the axis that matters for a commit-message generator. Ranking a +/// typed query is the picker's job, and it uses the app's single shared +/// `fuzzy_score` so the two cannot rank the same query differently. +pub fn filter_models(models: &[ModelInfo], filter: ModelFilter) -> Vec<&ModelInfo> { + models + .iter() + .filter(|model| !filter.text_only || model.emits_text) + // `Unknown` survives `tools_only` deliberately. Dropping it would empty + // the Gemini and OpenAI lists entirely, since neither advertises tool + // support. + .filter(|model| !filter.tools_only || model.tool_support != ToolSupport::Unsupported) + .filter(|model| !filter.free_only || model.is_free()) + .filter(|model| filter.show_variants || !model.is_variant) + .collect() +} + +// ============================================================================ +// Pinned-model validation +// ============================================================================ + +/// What the catalogue can say about the model the user has pinned. +/// +/// `settings.ai.model` is never auto-rewritten from any of these: it is user +/// intent, and silently retargeting it is how someone ends up billed for a +/// model they did not choose. +#[derive(Debug, Clone, PartialEq)] +pub enum PinnedModelStatus { + Known(Box), + Missing { + suggestion: Option, + }, + /// In the catalogue but in conflict with current settings. + Incompatible { + reason: String, + }, + /// No catalogue to check against — offline, no key, or bundled-only. + Unverified, +} + +/// Classify a pinned model. Pure; `use_tools` comes from settings so the +/// conflict surfaces here rather than as an opaque 404 at request time. +pub fn classify_pinned( + pinned: &str, + catalog: &[ModelInfo], + source: CatalogSource, + use_tools: bool, +) -> PinnedModelStatus { + let pinned = pinned.trim(); + if pinned.is_empty() { + return PinnedModelStatus::Missing { suggestion: None }; + } + + if let Some(model) = catalog.iter().find(|model| model.id == pinned) { + if use_tools && model.tool_support == ToolSupport::Unsupported { + return PinnedModelStatus::Incompatible { + reason: format!( + "`{}` does not support tool calling. Turn off \"Let the model read files\" \ + or choose a model marked Tools.", + model.id + ), + }; + } + return PinnedModelStatus::Known(Box::new(model.clone())); + } + + // A bundled or absent catalogue is not evidence the model is gone, and + // crying wolf offline is worse than staying quiet. + if matches!(source, CatalogSource::Bundled) || catalog.is_empty() { + return PinnedModelStatus::Unverified; + } + + PinnedModelStatus::Missing { + suggestion: closest_model_id(pinned, catalog), + } +} + +/// Minimum shared prefix before a "did you mean" is offered. Below this the +/// two ids are not the same model family and a suggestion would be noise. +const MIN_SUGGESTION_PREFIX: usize = 4; + +/// The catalogue id most likely to be what a retired pin was replaced by. +/// +/// Ranked by shared leading characters, not by [`fuzzy_score`]: fuzzy +/// subsequence matching answers "does the user's typing appear in this id", +/// which is right for a search box and wrong here. `openai/gpt-4o-mini` +/// contains a `4` that no successor id has, so fuzzy matching finds nothing at +/// all — while the shared `openai/gpt-` prefix is exactly the signal wanted. +pub fn closest_model_id(pinned: &str, catalog: &[ModelInfo]) -> Option { + let pinned = pinned.trim().to_ascii_lowercase(); + catalog + .iter() + .map(|model| { + ( + common_prefix_len(&pinned, &model.id.to_ascii_lowercase()), + model, + ) + }) + .filter(|(shared, _)| *shared >= MIN_SUGGESTION_PREFIX) + // Longest shared prefix wins; ties break toward the shorter id, which + // is the plain model rather than a dated snapshot of it. + .min_by_key(|(shared, model)| (usize::MAX - shared, model.id.len())) + .map(|(_, model)| model.id.clone()) +} + +fn common_prefix_len(a: &str, b: &str) -> usize { + a.chars() + .zip(b.chars()) + .take_while(|(x, y)| x == y) + .map(|(x, _)| x.len_utf8()) + .sum() +} + +/// How long a catalogue fetch may take before it is abandoned. +pub const CATALOG_TIMEOUT: Duration = CATALOG_REQUEST_TIMEOUT; + +#[cfg(test)] +mod tests { + use super::*; + + fn model(id: &str) -> ModelInfo { + ModelInfo { + id: id.to_string(), + display_name: id.to_string(), + context_length: Some(128_000), + max_output_tokens: Some(4096), + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + tool_support: ToolSupport::Supported, + emits_text: true, + is_variant: false, + created: None, + } + } + + // ── OpenRouter fixture ──────────────────────────────────────── + // + // Trimmed from a real response, keeping exactly the records that broke + // naive derives: a negative-price BYOK rebate row, a null + // `top_provider.context_length`, a record missing `reasoning` and + // `benchmarks` entirely, an alias row, and a non-text model. + + const OPENROUTER_FIXTURE: &str = r#"{ + "data": [ + { + "id": "google/gemini-3.1-flash-lite", + "canonical_slug": "google/gemini-3.1-flash-lite", + "name": "Google: Gemini 3.1 Flash Lite", + "created": 1771200000, + "description": "Fast and cheap.", + "context_length": 1048576, + "architecture": { "output_modalities": ["text"], "input_modalities": ["text"] }, + "pricing": { "prompt": "0.00000025", "completion": "0.0000015" }, + "top_provider": { "context_length": 1048576, "max_completion_tokens": 65536 }, + "supported_parameters": ["tools", "temperature", "max_tokens"], + "reasoning": { "supported": true } + }, + { + "id": "someone/byok-rebate-model", + "name": "BYOK Rebate", + "created": 1770000000, + "context_length": 200000, + "architecture": { "output_modalities": ["text"] }, + "pricing": { "prompt": "-0.0000001", "completion": "-0.0000002" }, + "top_provider": { "context_length": null, "max_completion_tokens": null }, + "supported_parameters": ["tools"] + }, + { + "id": "openai/gpt-5.6-luna:free", + "name": "GPT-5.6 Luna (free)", + "created": 1772000000, + "context_length": 1047576, + "architecture": { "output_modalities": ["text"] }, + "pricing": { "prompt": "0", "completion": "0" }, + "top_provider": { "context_length": 1047576, "max_completion_tokens": 32768 }, + "supported_parameters": ["tools"] + }, + { + "id": "vendor/aliased-model", + "name": "Aliased", + "created": 1769000000, + "context_length": 8192, + "architecture": { "output_modalities": ["text"] }, + "pricing": { "prompt": "0.000001", "completion": "0.000002" }, + "top_provider": { "context_length": 8192, "max_completion_tokens": 4096 }, + "supported_parameters": ["tools"], + "alias_target": "vendor/real-model" + }, + { + "id": "vendor/image-model", + "name": "Image Only", + "created": 1768000000, + "context_length": 4096, + "architecture": { "output_modalities": ["image"] }, + "pricing": { "prompt": "0.00001", "completion": "0.00002" }, + "top_provider": { "context_length": 4096, "max_completion_tokens": 1024 }, + "supported_parameters": ["temperature"] + } + ] + }"#; + + fn openrouter_models() -> Vec { + parse_models( + AiProvider::OpenRouter, + &serde_json::from_str(OPENROUTER_FIXTURE).unwrap(), + ) + } + + #[test] + fn openrouter_fixture_parses_every_record() { + assert_eq!(openrouter_models().len(), 5); + } + + #[test] + fn openrouter_prices_convert_from_per_token_strings_to_per_mtok() { + let models = openrouter_models(); + let flash = &models[0]; + assert_eq!(flash.prompt_price_per_mtok, Some(0.25)); + assert_eq!(flash.completion_price_per_mtok, Some(1.5)); + } + + /// Five real models report negative prices (BYOK rebate rows), so nothing + /// may assume `>= 0`. + #[test] + fn openrouter_negative_prices_are_preserved_not_clamped_away() { + let models = openrouter_models(); + let rebate = models.iter().find(|m| m.id.contains("byok")).unwrap(); + let price = rebate.prompt_price_per_mtok.unwrap(); + assert!(price < 0.0, "a negative price must survive parsing"); + assert!((price - -0.1).abs() < 1e-9, "got {price}"); + } + + #[test] + fn a_null_top_provider_context_falls_back_to_the_top_level_field() { + let models = openrouter_models(); + let rebate = models.iter().find(|m| m.id.contains("byok")).unwrap(); + assert_eq!(rebate.context_length, Some(200_000)); + assert_eq!(rebate.max_output_tokens, None); + } + + #[test] + fn a_record_missing_optional_objects_still_parses() { + // The BYOK row carries no `reasoning` and no `benchmarks`; 181 of 421 + // real records are missing at least one. + assert!(openrouter_models().iter().any(|m| m.id.contains("byok"))); + } + + #[test] + fn free_and_alias_rows_are_marked_as_variants() { + let models = openrouter_models(); + assert!( + models + .iter() + .find(|m| m.id.ends_with(":free")) + .unwrap() + .is_variant + ); + assert!( + models + .iter() + .find(|m| m.id.contains("aliased")) + .unwrap() + .is_variant + ); + assert!(!models[0].is_variant); + } + + #[test] + fn variant_detection_covers_every_documented_form() { + assert!(is_openrouter_variant("a/b:free", None)); + assert!(is_openrouter_variant("a/b:batch", None)); + assert!(is_openrouter_variant("a/b-latest", None)); + assert!(is_openrouter_variant("a/b", Some("a/c"))); + assert!(!is_openrouter_variant("a/b", None)); + } + + #[test] + fn a_non_text_model_is_flagged_and_filtered_out_by_default() { + let models = openrouter_models(); + let image = models.iter().find(|m| m.id.contains("image")).unwrap(); + assert!(!image.emits_text); + let kept = filter_models(&models, ModelFilter::default()); + assert!(!kept.iter().any(|m| m.id.contains("image"))); + } + + // ── other providers ─────────────────────────────────────────── + + #[test] + fn anthropic_parses_display_name_and_limits() { + let json = serde_json::json!({ + "data": [{ + "id": "claude-haiku-4-5", + "display_name": "Claude Haiku 4.5", + "max_input_tokens": 200000, + "max_tokens": 64000, + "capabilities": { "tool_use": true } + }] + }); + let models = parse_models(AiProvider::Anthropic, &json); + assert_eq!(models.len(), 1); + assert_eq!(models[0].display_name, "Claude Haiku 4.5"); + assert_eq!(models[0].context_length, Some(200_000)); + assert_eq!(models[0].tool_support, ToolSupport::Supported); + } + + #[test] + fn gemini_strips_the_models_prefix_and_drops_non_generative_models() { + let json = serde_json::json!({ + "models": [ + { + "name": "models/gemini-3.1-flash-lite", + "displayName": "Gemini 3.1 Flash Lite", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": ["generateContent", "countTokens"] + }, + { + "name": "models/text-embedding-004", + "displayName": "Embedding 004", + "supportedGenerationMethods": ["embedContent"] + } + ] + }); + let models = parse_models(AiProvider::Gemini, &json); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "gemini-3.1-flash-lite"); + // Gemini advertises no tool flag; say nothing rather than guess. + assert_eq!(models[0].tool_support, ToolSupport::Unknown); + } + + #[test] + fn openai_drops_non_chat_models_and_anything_with_a_shutdown_date() { + let json = serde_json::json!({ + "data": [ + { "id": "gpt-5.6-luna", "created": 1 }, + { "id": "text-embedding-3-small", "created": 2 }, + { "id": "whisper-1", "created": 3 }, + { "id": "gpt-4o-mini", "created": 4, "shutdown_date": "2026-12-11" }, + { "id": "o4-mini", "created": 5 } + ] + }); + let ids: Vec = parse_models(AiProvider::OpenAi, &json) + .into_iter() + .map(|m| m.id) + .collect(); + assert_eq!(ids, vec!["gpt-5.6-luna".to_string()]); + } + + #[test] + fn o_series_detection_does_not_catch_ordinary_names() { + assert!(is_openai_o_series("o3")); + assert!(is_openai_o_series("o4-mini")); + assert!(is_openai_o_series("o1-preview")); + assert!(!is_openai_o_series("openai/gpt-5")); + assert!(!is_openai_o_series("omni-moderation-latest")); + assert!(!is_openai_o_series("gpt-5")); + } + + #[test] + fn a_payload_with_no_data_array_yields_an_empty_list_not_a_panic() { + for provider in AiProvider::ALL { + assert!(parse_models(*provider, &serde_json::json!({ "error": "nope" })).is_empty()); + } + } + + // ── filtering ───────────────────────────────────────────────── + + fn filter_fixture() -> Vec { + let mut supported = model("vendor/tools-model"); + supported.tool_support = ToolSupport::Supported; + let mut unsupported = model("vendor/no-tools-model"); + unsupported.tool_support = ToolSupport::Unsupported; + let mut unknown = model("vendor/unknown-tools-model"); + unknown.tool_support = ToolSupport::Unknown; + let mut free = model("vendor/free-model:free"); + free.prompt_price_per_mtok = Some(0.0); + free.completion_price_per_mtok = Some(0.0); + free.is_variant = true; + let mut image = model("vendor/image-model"); + image.emits_text = false; + vec![supported, unsupported, unknown, free, image] + } + + #[test] + fn tools_only_keeps_unknown_so_gemini_and_openai_lists_do_not_empty() { + let models = filter_fixture(); + let filter = ModelFilter { + tools_only: true, + ..ModelFilter::default() + }; + let ids: Vec<&str> = filter_models(&models, filter) + .into_iter() + .map(|m| m.id.as_str()) + .collect(); + assert!(ids.contains(&"vendor/tools-model")); + assert!(ids.contains(&"vendor/unknown-tools-model")); + assert!(!ids.contains(&"vendor/no-tools-model")); + } + + #[test] + fn variants_are_hidden_by_default_and_revealed_on_request() { + let models = filter_fixture(); + let hidden = filter_models(&models, ModelFilter::default()); + assert!(!hidden.iter().any(|m| m.is_variant)); + + let shown = filter_models( + &models, + ModelFilter { + show_variants: true, + ..ModelFilter::default() + }, + ); + assert!(shown.iter().any(|m| m.is_variant)); + } + + #[test] + fn free_only_needs_a_reported_price_of_exactly_zero() { + let models = filter_fixture(); + let filter = ModelFilter { + free_only: true, + show_variants: true, + ..ModelFilter::default() + }; + let ids: Vec<&str> = filter_models(&models, filter) + .into_iter() + .map(|m| m.id.as_str()) + .collect(); + assert_eq!(ids, vec!["vendor/free-model:free"]); + } + + #[test] + fn every_flag_combination_terminates_and_never_exceeds_the_input() { + let models = filter_fixture(); + for tools_only in [false, true] { + for text_only in [false, true] { + for free_only in [false, true] { + for show_variants in [false, true] { + let filter = ModelFilter { + tools_only, + text_only, + free_only, + show_variants, + }; + assert!(filter_models(&models, filter).len() <= models.len()); + } + } + } + } + } + + #[test] + fn filtering_preserves_the_servers_order() { + let models = openrouter_models(); + let kept = filter_models( + &models, + ModelFilter { + show_variants: true, + ..ModelFilter::default() + }, + ); + assert_eq!(kept[0].id, models[0].id); + } + + // ── freshness and cache resolution ──────────────────────────── + + #[test] + fn freshness_boundaries() { + let now = 1_000_000; + assert_eq!(freshness(now, now), CatalogFreshness::Fresh); + assert_eq!( + freshness(now - CATALOG_TTL_SECS + 1, now), + CatalogFreshness::Fresh + ); + assert_eq!( + freshness(now - CATALOG_TTL_SECS, now), + CatalogFreshness::Stale + ); + assert_eq!( + freshness(now - CATALOG_STALE_SECS + 1, now), + CatalogFreshness::Stale + ); + assert_eq!( + freshness(now - CATALOG_STALE_SECS, now), + CatalogFreshness::Expired + ); + } + + #[test] + fn a_future_timestamp_reads_as_fresh_rather_than_refetching_forever() { + assert_eq!(freshness(2_000_000, 1_000_000), CatalogFreshness::Fresh); + } + + #[test] + fn resolve_prefers_the_cache_and_falls_back_to_bundled() { + let cached = CachedCatalog { + schema: CATALOG_SCHEMA, + fetched_at: 42, + models: vec![model("cached/model")], + }; + let (models, source) = resolve_catalog(AiProvider::OpenAi, Some(cached)); + assert_eq!(models[0].id, "cached/model"); + assert_eq!(source, CatalogSource::Cache { fetched_at: 42 }); + + let (models, source) = resolve_catalog(AiProvider::OpenAi, None); + assert_eq!(source, CatalogSource::Bundled); + assert!(models + .iter() + .any(|m| m.id == AiProvider::OpenAi.default_model())); + } + + #[test] + fn a_cache_from_an_older_schema_is_discarded() { + let cached = CachedCatalog { + schema: CATALOG_SCHEMA + 1, + fetched_at: 42, + models: vec![model("cached/model")], + }; + let (_, source) = resolve_catalog(AiProvider::OpenAi, Some(cached)); + assert_eq!(source, CatalogSource::Bundled); + } + + #[test] + fn an_empty_cache_falls_back_rather_than_rendering_nothing() { + let cached = CachedCatalog { + schema: CATALOG_SCHEMA, + fetched_at: 42, + models: Vec::new(), + }; + let (models, source) = resolve_catalog(AiProvider::Gemini, Some(cached)); + assert_eq!(source, CatalogSource::Bundled); + assert!(!models.is_empty()); + } + + #[test] + fn only_openrouters_catalogue_is_public() { + assert!(!catalog_needs_key(AiProvider::OpenRouter)); + for provider in [ + AiProvider::Gemini, + AiProvider::OpenAi, + AiProvider::Anthropic, + AiProvider::DeepSeek, + ] { + assert!(catalog_needs_key(provider)); + } + } + + // ── pinned-model classification ─────────────────────────────── + + #[test] + fn a_pinned_model_in_the_catalogue_is_known() { + let models = vec![model("vendor/a")]; + assert!(matches!( + classify_pinned("vendor/a", &models, CatalogSource::Live, true), + PinnedModelStatus::Known(_) + )); + } + + #[test] + fn a_missing_pin_suggests_the_closest_match_but_never_applies_it() { + let models = vec![model("openai/gpt-5.6-sol"), model("vendor/unrelated")]; + match classify_pinned("openai/gpt-4o-mini", &models, CatalogSource::Live, false) { + PinnedModelStatus::Missing { suggestion } => { + assert_eq!(suggestion.as_deref(), Some("openai/gpt-5.6-sol")); + } + other => panic!("expected Missing, got {other:?}"), + } + } + + #[test] + fn a_suggestion_is_ranked_by_shared_family_not_by_search_relevance() { + let models = vec![ + model("anthropic/claude-haiku-4.5"), + model("openai/gpt-5.6-sol"), + model("openai/gpt-5.6-luna"), + ]; + // Longest shared prefix wins, and the tie between the two `gpt-5.6-` + // ids breaks toward the shorter id. + assert_eq!( + closest_model_id("openai/gpt-5.6-x", &models).as_deref(), + Some("openai/gpt-5.6-sol") + ); + assert_eq!( + closest_model_id("anthropic/claude-opus-4.6", &models).as_deref(), + Some("anthropic/claude-haiku-4.5") + ); + } + + /// A pin with nothing in common with anything on offer gets no + /// suggestion. An unrelated id presented as "use this instead" is worse + /// than saying nothing. + #[test] + fn no_suggestion_is_offered_when_nothing_is_in_the_same_family() { + let models = vec![model("anthropic/claude-haiku-4.5")]; + assert_eq!(closest_model_id("zzz/unrelated-model", &models), None); + assert_eq!(closest_model_id("", &models), None); + } + + #[test] + fn suggestions_ignore_case() { + let models = vec![model("openai/gpt-5.6-luna")]; + assert_eq!( + closest_model_id("OpenAI/GPT-5.6-Sol", &models).as_deref(), + Some("openai/gpt-5.6-luna") + ); + } + + /// The highest-value check: an opaque runtime 404 becomes a settings-time + /// warning. + #[test] + fn a_tool_incompatible_pin_is_caught_before_the_request_is_ever_sent() { + let mut no_tools = model("mistralai/mistral-7b-instruct"); + no_tools.tool_support = ToolSupport::Unsupported; + match classify_pinned( + "mistralai/mistral-7b-instruct", + &[no_tools], + CatalogSource::Live, + true, + ) { + PinnedModelStatus::Incompatible { reason } => { + assert!(reason.contains("does not support tool calling")); + } + other => panic!("expected Incompatible, got {other:?}"), + } + } + + #[test] + fn the_same_pin_is_fine_once_tools_are_off() { + let mut no_tools = model("mistralai/mistral-7b-instruct"); + no_tools.tool_support = ToolSupport::Unsupported; + assert!(matches!( + classify_pinned( + "mistralai/mistral-7b-instruct", + &[no_tools], + CatalogSource::Live, + false + ), + PinnedModelStatus::Known(_) + )); + } + + /// A bundled or absent catalogue is not evidence a model is gone. Crying + /// wolf offline is worse than staying quiet. + #[test] + fn a_bundled_or_empty_catalogue_never_warns_about_an_unknown_pin() { + assert_eq!( + classify_pinned("anything", &[model("x")], CatalogSource::Bundled, true), + PinnedModelStatus::Unverified + ); + assert_eq!( + classify_pinned("anything", &[], CatalogSource::Live, true), + PinnedModelStatus::Unverified + ); + } + + #[test] + fn an_empty_pin_reads_as_missing_with_nothing_to_suggest() { + assert_eq!( + classify_pinned(" ", &[model("x")], CatalogSource::Live, true), + PinnedModelStatus::Missing { suggestion: None } + ); + } + + // ── presentation helpers ────────────────────────────────────── + + #[test] + fn context_windows_render_the_way_provider_docs_do() { + assert_eq!(format_context(1_048_576), "1.05M"); + assert_eq!(format_context(1_000_000), "1M"); + assert_eq!(format_context(128_000), "128K"); + assert_eq!(format_context(4_096), "4K"); + assert_eq!(format_context(512), "512"); + } + + #[test] + fn the_summary_line_omits_what_a_provider_does_not_report() { + let mut info = model("vendor/a"); + info.tool_support = ToolSupport::Unknown; + assert_eq!(info.summary_line(), "128K ctx"); + + info.prompt_price_per_mtok = Some(0.25); + info.completion_price_per_mtok = Some(1.5); + info.tool_support = ToolSupport::Supported; + assert_eq!( + info.summary_line(), + "128K ctx · $0.25/$1.50 per Mtok · Tools" + ); + } +} diff --git a/crates/rgitui_ai/src/catalog/static_catalog.rs b/crates/rgitui_ai/src/catalog/static_catalog.rs new file mode 100644 index 00000000..40390cd8 --- /dev/null +++ b/crates/rgitui_ai/src/catalog/static_catalog.rs @@ -0,0 +1,357 @@ +//! The bundled model table. +//! +//! Successor to the hardcoded `Vec<&str>` that used to live inside a render +//! function in the settings view. It serves two jobs: +//! +//! 1. The last-resort catalogue when there is no cache and no network. +//! 2. Metadata enrichment for OpenAI and DeepSeek, whose `/models` endpoints +//! report neither a context window nor tool support, so a live fetch alone +//! would render a list with every column blank. +//! +//! It goes stale between releases. That is acceptable for a fallback — the +//! picker labels the source, so "shipped with the app" never masquerades as +//! "current" — but it is the reason the live catalogue exists. + +use super::{ModelInfo, ToolSupport}; +use rgitui_settings::AiProvider; + +pub(crate) struct StaticModel { + pub id: &'static str, + pub display_name: &'static str, + pub context_length: u32, + pub max_output_tokens: u32, + pub tool_support: ToolSupport, +} + +const fn model( + id: &'static str, + display_name: &'static str, + context_length: u32, + max_output_tokens: u32, + tool_support: ToolSupport, +) -> StaticModel { + StaticModel { + id, + display_name, + context_length, + max_output_tokens, + tool_support, + } +} + +const GEMINI: &[StaticModel] = &[ + model( + "gemini-3.1-flash-lite", + "Gemini 3.1 Flash Lite", + 1_048_576, + 65_536, + ToolSupport::Supported, + ), + model( + "gemini-3.1-pro-preview", + "Gemini 3.1 Pro (preview)", + 1_048_576, + 65_536, + ToolSupport::Supported, + ), + model( + "gemini-3-flash-preview", + "Gemini 3 Flash (preview)", + 1_048_576, + 65_536, + ToolSupport::Supported, + ), + model( + "gemini-2.5-flash", + "Gemini 2.5 Flash", + 1_048_576, + 65_536, + ToolSupport::Supported, + ), + model( + "gemini-2.5-pro", + "Gemini 2.5 Pro", + 1_048_576, + 65_536, + ToolSupport::Supported, + ), +]; + +// `o3` and `o4-mini` are deliberately absent: the o-series rejects both +// `max_tokens` and a non-default `temperature`, so every request this app +// builds 400s against them, and they are being retired regardless. +const OPENAI: &[StaticModel] = &[ + model( + "gpt-5.6-luna", + "GPT-5.6 Luna", + 1_047_576, + 32_768, + ToolSupport::Supported, + ), + model( + "gpt-5.4", + "GPT-5.4", + 400_000, + 128_000, + ToolSupport::Supported, + ), + model("gpt-5", "GPT-5", 400_000, 128_000, ToolSupport::Supported), + model( + "gpt-5-mini", + "GPT-5 mini", + 400_000, + 128_000, + ToolSupport::Supported, + ), + model( + "gpt-5-nano", + "GPT-5 nano", + 400_000, + 128_000, + ToolSupport::Supported, + ), +]; + +// `claude-sonnet-4-5-20241022` is deliberately absent: `20241022` is the Claude +// 3.5 snapshot date attached to a 4.5 name, so it was a guaranteed 404. +const ANTHROPIC: &[StaticModel] = &[ + model( + "claude-haiku-4-5", + "Claude Haiku 4.5", + 200_000, + 64_000, + ToolSupport::Supported, + ), + model( + "claude-sonnet-4-6", + "Claude Sonnet 4.6", + 200_000, + 64_000, + ToolSupport::Supported, + ), + model( + "claude-opus-4-6", + "Claude Opus 4.6", + 200_000, + 64_000, + ToolSupport::Supported, + ), +]; + +const DEEPSEEK: &[StaticModel] = &[ + model( + "deepseek-v4-flash", + "DeepSeek V4 Flash", + 128_000, + 8_192, + ToolSupport::Supported, + ), + model( + "deepseek-v4-pro", + "DeepSeek V4 Pro", + 128_000, + 8_192, + ToolSupport::Supported, + ), +]; + +// OpenRouter's real catalogue is fetched live and needs no key, so the bundled +// slice only has to cover the offline case with a handful of safe defaults. +const OPENROUTER: &[StaticModel] = &[ + model( + "google/gemini-3.1-flash-lite", + "Gemini 3.1 Flash Lite", + 1_048_576, + 65_536, + ToolSupport::Supported, + ), + model( + "openai/gpt-5.6-luna", + "GPT-5.6 Luna", + 1_047_576, + 32_768, + ToolSupport::Supported, + ), + model( + "anthropic/claude-haiku-4.5", + "Claude Haiku 4.5", + 200_000, + 64_000, + ToolSupport::Supported, + ), + model( + "deepseek/deepseek-v4-flash", + "DeepSeek V4 Flash", + 128_000, + 8_192, + ToolSupport::Supported, + ), +]; + +pub(crate) fn static_models(provider: AiProvider) -> &'static [StaticModel] { + match provider { + AiProvider::Gemini => GEMINI, + AiProvider::OpenAi => OPENAI, + AiProvider::Anthropic => ANTHROPIC, + AiProvider::DeepSeek => DEEPSEEK, + AiProvider::OpenRouter => OPENROUTER, + } +} + +/// The bundled catalogue for a provider, as `ModelInfo` rows. +pub fn bundled_catalog(provider: AiProvider) -> Vec { + static_models(provider) + .iter() + .map(|m| ModelInfo { + id: m.id.to_string(), + display_name: m.display_name.to_string(), + context_length: Some(m.context_length), + max_output_tokens: Some(m.max_output_tokens), + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + tool_support: m.tool_support, + emits_text: true, + is_variant: false, + created: None, + }) + .collect() +} + +/// Fill in what a provider's live `/models` endpoint does not report. +/// +/// OpenAI and DeepSeek return an id and nothing else, so without this every +/// row in the picker would show a blank context window and no tool badge even +/// when the fetch succeeded. +pub fn enrich_from_static(provider: AiProvider, models: &mut [ModelInfo]) { + let table = static_models(provider); + for info in models.iter_mut() { + let Some(known) = table.iter().find(|m| m.id == info.id) else { + continue; + }; + if info.display_name.is_empty() || info.display_name == info.id { + info.display_name = known.display_name.to_string(); + } + info.context_length = info.context_length.or(Some(known.context_length)); + info.max_output_tokens = info.max_output_tokens.or(Some(known.max_output_tokens)); + if info.tool_support == ToolSupport::Unknown { + info.tool_support = known.tool_support; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_provider_has_a_bundled_catalogue_containing_its_default() { + for provider in AiProvider::ALL { + let models = bundled_catalog(*provider); + assert!( + !models.is_empty(), + "{} has no bundled models", + provider.id() + ); + assert!( + models.iter().any(|m| m.id == provider.default_model()), + "{}'s default model {} is not in its own list — the exact bug that \ + rendered the Model row with nothing selected", + provider.id(), + provider.default_model() + ); + } + } + + #[test] + fn the_broken_model_ids_are_gone() { + let dead = [ + "claude-sonnet-4-5-20241022", + "o3", + "o4-mini", + "gemini-2.0-flash", + ]; + for provider in AiProvider::ALL { + for model in bundled_catalog(*provider) { + assert!( + !dead.contains(&model.id.as_str()), + "{} still offers the retired id {}", + provider.id(), + model.id + ); + } + } + } + + #[test] + fn bundled_ids_are_unique_within_a_provider() { + for provider in AiProvider::ALL { + let mut ids: Vec = bundled_catalog(*provider) + .into_iter() + .map(|m| m.id) + .collect(); + let count = ids.len(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), count, "{} has duplicate ids", provider.id()); + } + } + + #[test] + fn enrichment_fills_gaps_without_overwriting_live_data() { + let mut models = vec![ + ModelInfo { + id: "gpt-5.4".into(), + display_name: "gpt-5.4".into(), + context_length: None, + max_output_tokens: None, + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + tool_support: ToolSupport::Unknown, + emits_text: true, + is_variant: false, + created: None, + }, + ModelInfo { + id: "gpt-5".into(), + display_name: "Live Name".into(), + context_length: Some(1), + max_output_tokens: Some(2), + prompt_price_per_mtok: Some(3.0), + completion_price_per_mtok: None, + tool_support: ToolSupport::Unsupported, + emits_text: true, + is_variant: false, + created: None, + }, + ]; + enrich_from_static(AiProvider::OpenAi, &mut models); + + assert_eq!(models[0].display_name, "GPT-5.4"); + assert_eq!(models[0].context_length, Some(400_000)); + assert_eq!(models[0].tool_support, ToolSupport::Supported); + + // Live values win. + assert_eq!(models[1].display_name, "Live Name"); + assert_eq!(models[1].context_length, Some(1)); + assert_eq!(models[1].tool_support, ToolSupport::Unsupported); + } + + #[test] + fn an_unknown_id_passes_through_enrichment_untouched() { + let mut models = vec![ModelInfo { + id: "some-future-model".into(), + display_name: "some-future-model".into(), + context_length: None, + max_output_tokens: None, + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + tool_support: ToolSupport::Unknown, + emits_text: true, + is_variant: false, + created: None, + }]; + enrich_from_static(AiProvider::OpenAi, &mut models); + assert_eq!(models[0].context_length, None); + assert_eq!(models[0].tool_support, ToolSupport::Unknown); + } +} diff --git a/crates/rgitui_ai/src/http.rs b/crates/rgitui_ai/src/http.rs new file mode 100644 index 00000000..c2ed4a35 --- /dev/null +++ b/crates/rgitui_ai/src/http.rs @@ -0,0 +1,144 @@ +//! Shared HTTP plumbing for every provider call. +//! +//! Three things live here because getting any one of them wrong hangs the app +//! or exhausts memory, and they must not be re-derived per provider: +//! +//! - a **deadline** on every request. `ReqwestClient::new()` sets a connect +//! timeout only, so a provider that accepts the TCP connection and then +//! stalls used to leave the spinner running until the app was restarted. +//! - a **cap** on the response body. An arbitrary endpoint — which +//! `base_url_override` now makes reachable — is exactly the case where an +//! unbounded `read_to_end` becomes a real OOM. +//! - **retry with backoff** that honours `Retry-After`, so a 429 or a +//! transient 503 is not a hard failure. + +use anyhow::{Context as _, Result}; +use futures::AsyncReadExt; +use gpui::http_client::{AsyncBody, Response}; +use std::time::Duration; + +/// Deadline for a generation request, including its response body. Tool- +/// calling generations issue several of these in sequence, so this is a +/// per-request budget rather than a whole-generation one. +pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Deadline for a catalogue fetch. Shorter than a generation: nothing the user +/// is waiting on blocks behind it, and the cached list is already on screen. +pub(crate) const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); + +/// Hard cap on a response body. Well beyond any legitimate completion or +/// catalogue (the largest real payload measured is OpenRouter's ~700 KB dump). +const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024; + +/// How many times a retryable status is retried before giving up. +pub(crate) const MAX_RETRIES: u32 = 2; + +/// Read a response body, refusing to grow past [`MAX_RESPONSE_BYTES`]. +pub(crate) async fn read_response_body(response: &mut Response) -> Result> { + let mut body = Vec::new(); + response + .body_mut() + .take(MAX_RESPONSE_BYTES as u64 + 1) + .read_to_end(&mut body) + .await + .context("Failed to read the response body")?; + if body.len() > MAX_RESPONSE_BYTES { + anyhow::bail!( + "The response was larger than {} MB and was refused. If you set a custom \ + base URL, check that it points at an OpenAI-compatible API.", + MAX_RESPONSE_BYTES / (1024 * 1024) + ); + } + Ok(body) +} + +/// Whether a status is worth retrying: rate limits and transient server +/// errors, never a 4xx the request itself caused. +pub(crate) fn is_retryable(status: u16) -> bool { + status == 429 || status == 408 || (500..=599).contains(&status) +} + +/// How long to wait before retry `attempt` (0-based), honouring the server's +/// `Retry-After` when it sent one. +/// +/// Pure so the backoff schedule is testable without sleeping. +pub(crate) fn retry_delay(attempt: u32, retry_after: Option) -> Duration { + let backoff = Duration::from_millis(500u64 << attempt.min(6)); + match retry_after { + // Cap what a server can ask us to wait: a header asking for ten + // minutes should surface as a failure the user can act on, not a + // spinner that appears hung. + Some(after) => after.min(Duration::from_secs(30)).max(backoff), + None => backoff, + } +} + +/// Parse a `Retry-After` header. Only the delta-seconds form is honoured; the +/// HTTP-date form is rare in practice and a wrong parse would be worse than +/// falling back to plain backoff. +pub(crate) fn parse_retry_after(value: Option<&str>) -> Option { + let seconds: u64 = value?.trim().parse().ok()?; + Some(Duration::from_secs(seconds)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limits_and_server_errors_retry_but_client_errors_do_not() { + assert!(is_retryable(429)); + assert!(is_retryable(408)); + assert!(is_retryable(503)); + assert!(is_retryable(500)); + assert!(!is_retryable(400)); + assert!(!is_retryable(401)); + assert!(!is_retryable(404)); + assert!(!is_retryable(200)); + } + + #[test] + fn backoff_grows_exponentially_when_the_server_says_nothing() { + assert_eq!(retry_delay(0, None), Duration::from_millis(500)); + assert_eq!(retry_delay(1, None), Duration::from_millis(1000)); + assert_eq!(retry_delay(2, None), Duration::from_millis(2000)); + } + + #[test] + fn retry_after_wins_when_it_asks_for_longer_than_the_backoff() { + assert_eq!( + retry_delay(0, Some(Duration::from_secs(5))), + Duration::from_secs(5) + ); + } + + #[test] + fn a_retry_after_shorter_than_the_backoff_does_not_shorten_it() { + assert_eq!( + retry_delay(2, Some(Duration::from_millis(100))), + Duration::from_millis(2000) + ); + } + + #[test] + fn an_unreasonable_retry_after_is_capped() { + assert_eq!( + retry_delay(0, Some(Duration::from_secs(600))), + Duration::from_secs(30) + ); + } + + #[test] + fn retry_after_parses_only_the_delta_seconds_form() { + assert_eq!(parse_retry_after(Some("7")), Some(Duration::from_secs(7))); + assert_eq!( + parse_retry_after(Some(" 12 ")), + Some(Duration::from_secs(12)) + ); + assert_eq!( + parse_retry_after(Some("Wed, 21 Oct 2026 07:28:00 GMT")), + None + ); + assert_eq!(parse_retry_after(None), None); + } +} diff --git a/crates/rgitui_ai/src/lib.rs b/crates/rgitui_ai/src/lib.rs index 7566e7fc..0c97643b 100644 --- a/crates/rgitui_ai/src/lib.rs +++ b/crates/rgitui_ai/src/lib.rs @@ -1,80 +1,126 @@ +//! AI commit-message generation. +//! +//! ## Threading +//! +//! Nothing here runs the provider call or a tool on the UI thread. The whole +//! provider dispatch — HTTP, JSON parsing, `git` spawns, `read_dir` walks — +//! lives inside one `cx.background_executor().spawn(...)`, and progress comes +//! back over an `mpsc` channel that a small foreground task drains into +//! [`AiEvent::ToolCallStarted`]. Only `this.update(...)` stays on the +//! foreground. +//! +//! ## Lifecycle +//! +//! Every generation carries a monotonic `generation` id and the repo path it +//! was requested for, mirroring `refresh_generation` in `rgitui_git`. The +//! workspace drops events from a superseded generation and routes the result +//! by repo path, so a message generated for one tab can never land in +//! another's commit box. + +pub mod catalog; +mod http; +mod prompt; +mod provider; mod tools; use anyhow::{Context as _, Result}; -use futures::AsyncReadExt; -use gpui::http_client::{AsyncBody, HttpClient, Method, Request, Response}; -use gpui::{AsyncApp, Context, EventEmitter, Task, WeakEntity}; -use rgitui_settings::SettingsState; +use futures::channel::mpsc; +use futures::StreamExt; +use gpui::http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Request}; +use gpui::{AsyncApp, BackgroundExecutor, Context, EventEmitter, Task, WeakEntity}; +use rgitui_settings::{AiProvider, SettingsState}; use serde::Deserialize; +use serde_json::Value; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; + +pub use prompt::CommitStyle; +pub use provider::{effective_host, validate_base_url, BaseUrlError}; pub use tools::{ - anthropic_tool_definitions, execute_tool, gemini_tool_definitions, openai_tool_definitions, - ToolCall, ToolResult, + anthropic_tool_definitions, denied_path, execute_tool, gemini_tool_definitions, + openai_tool_definitions, DeniedReason, ToolBudget, ToolCall, ToolResult, }; -/// Commit message style options. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CommitStyle { - /// Conventional Commits format: feat(scope): description - Conventional, - /// Plain English descriptive format - #[default] - Descriptive, - /// One-line brief format - Brief, -} - -impl std::str::FromStr for CommitStyle { - type Err = std::convert::Infallible; - - fn from_str(s: &str) -> Result { - Ok(match s { - "conventional" => Self::Conventional, - "brief" => Self::Brief, - _ => Self::Descriptive, - }) - } +use http::{ + is_retryable, parse_retry_after, read_response_body, retry_delay, MAX_RETRIES, REQUEST_TIMEOUT, +}; +use prompt::{build_prompt, collect_project_context}; +use provider::{ + auth_style, build_request_body, gemini_endpoint, openai_compat_endpoint, AuthStyle, + OpenAiCompatEndpoint, ANTHROPIC_ENDPOINT, ANTHROPIC_VERSION, +}; +use tools::execute_tool_within; + +/// Identifies one generation attempt, so a superseded or cross-tab result can +/// be dropped instead of overwriting the wrong commit box. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenerationId { + /// Monotonic per-`AiGenerator`. The same guard `apply_refresh_data` uses. + pub sequence: u64, + /// The checkout the request describes. Routing by this rather than by + /// `active_tab` is what keeps a message for repo `foo` out of repo `bar`. + pub repo_path: PathBuf, } -/// Events emitted by the AI system. +/// Events emitted by the AI system. Every variant carries its [`GenerationId`] +/// so a listener can tell whose result it is looking at. #[derive(Debug, Clone)] pub enum AiEvent { - GenerationStarted, - /// A tool call is being executed. Contains a human-readable description. - ToolCallStarted(String), - GenerationCompleted(String), - GenerationFailed(String), + GenerationStarted(GenerationId), + /// A tool call is being executed, with a human-readable description + /// ("Reading diff.rs", "Reading 20 recent commits"). + ToolCallStarted(GenerationId, String), + GenerationCompleted(GenerationId, String), + GenerationFailed(GenerationId, String), + /// A generation the user cancelled. Distinct from a failure so the UI can + /// clear its spinner without raising an error. + GenerationCancelled(GenerationId), + /// The request was refused by the client-side cooldown. Informational — + /// it does not clear an in-flight generation's spinner, which the old + /// code did, freeing the button to start a third request. + RateLimited { + wait: Duration, + }, } -/// Minimum time between AI requests (rate limiting). +/// Minimum time between AI requests (client-side rate limiting). const MIN_REQUEST_INTERVAL: Duration = Duration::from_secs(5); -/// Common parameters for all AI generation requests. -struct GenerateRequest<'a> { - client: &'a Arc, - api_key: &'a Option, - model: &'a str, - diff: &'a str, - summary: &'a str, - commit_style: CommitStyle, - project_context: Option<&'a str>, +/// Maximum number of provider round trips in one generation. +const MAX_TOOL_ITERATIONS: usize = 4; + +/// What a generation needs, gathered on the UI thread and then owned entirely +/// by the background task. +struct GenerateRequest { + client: Arc, + /// Used only for the retry backoff timer, so the retry schedule does not + /// need a second async runtime alongside GPUI's. + executor: BackgroundExecutor, + provider: AiProvider, + api_key: Option, + model: String, + prompt: String, + repo_path: PathBuf, + use_tools: bool, + base_url_override: String, + openrouter_attribution: bool, } -/// Context for tool-calling generation — repo to run tools against -/// and a callback to report tool execution status. -struct ToolContext<'a> { - repo_path: &'a Path, - on_status: &'a mut dyn FnMut(&str), -} +/// Reports tool progress from the background task to the foreground. +type StatusSender = mpsc::UnboundedSender; /// AI commit message generator. pub struct AiGenerator { - is_generating: bool, - last_result: Option, - last_error: Option, - last_request_time: Option, + /// The generation currently in flight, if any. + active: Option, + /// Holds the running task. Dropping it cancels the generation — which is + /// what makes both supersede-in-flight and the cancel button work. + task: Option>, + next_sequence: u64, + /// Stamped on *completion*, not on dispatch. Stamping at dispatch let a + /// 40-second generation permit a second concurrent one at t=5s. + last_request_finished: Option, } impl EventEmitter for AiGenerator {} @@ -88,66 +134,85 @@ impl Default for AiGenerator { impl AiGenerator { pub fn new() -> Self { Self { - is_generating: false, - last_result: None, - last_error: None, - last_request_time: None, + active: None, + task: None, + next_sequence: 0, + last_request_finished: None, } } pub fn is_generating(&self) -> bool { - self.is_generating + self.active.is_some() } - pub fn last_result(&self) -> Option<&str> { - self.last_result.as_deref() + /// The generation currently in flight, if any. + pub fn active(&self) -> Option<&GenerationId> { + self.active.as_ref() } - pub fn last_error(&self) -> Option<&str> { - self.last_error.as_deref() + /// How long the caller must wait before the cooldown allows another + /// request, or `None` if it may proceed now. + pub fn cooldown_remaining(&self) -> Option { + let finished = self.last_request_finished?; + MIN_REQUEST_INTERVAL.checked_sub(finished.elapsed()) } - /// Generate a commit message from a diff string and file summary. - pub fn generate_commit_message( - &mut self, - diff: String, - summary: String, - repo_path: PathBuf, - cx: &mut Context, - ) -> Task> { - self.generate_commit_message_with_tools(diff, summary, repo_path, false, cx) + /// Abandon the in-flight generation. Dropping the task cancels the HTTP + /// request and any pending tool execution with it. + pub fn cancel(&mut self, cx: &mut Context) { + let Some(id) = self.active.take() else { + return; + }; + self.task = None; + self.last_request_finished = Some(Instant::now()); + cx.emit(AiEvent::GenerationCancelled(id)); + cx.notify(); } /// Human-readable description of a tool call for status display. - fn describe_tool_call(call: &ToolCall) -> String { + pub fn describe_tool_call(call: &ToolCall) -> String { let args = &call.arguments; match call.name.as_str() { - "get_file_content" => { - let path = args["path"].as_str().unwrap_or("?"); - format!("Reading {}", path) + tools::TOOL_GET_FILE_CONTENT => { + format!("Reading {}", args["path"].as_str().unwrap_or("?")) } - "get_file_history" => { - let path = args["path"].as_str().unwrap_or("?"); - format!("File history: {}", path) + tools::TOOL_GET_FILE_HISTORY => { + format!("File history: {}", args["path"].as_str().unwrap_or("?")) } - "get_recent_commits" => { - let n = args["count"].as_u64().unwrap_or(5); - format!("Reading {} recent commits", n) + tools::TOOL_GET_RECENT_COMMITS => { + format!( + "Reading {} recent commits", + args["count"].as_u64().unwrap_or(5) + ) } - "get_diff" => { - let kind = args["kind"].as_str().unwrap_or("staged"); - format!("Reading {} diff", kind) + tools::TOOL_GET_DIFF => { + format!("Reading {} diff", args["kind"].as_str().unwrap_or("staged")) } - "get_branch_list" => "Listing branches".to_string(), - "get_file_tree" => { - let path = args["path"].as_str().unwrap_or("."); - format!("Scanning {}", path) + tools::TOOL_GET_BRANCH_LIST => "Listing branches".to_string(), + tools::TOOL_GET_FILE_TREE => { + format!("Scanning {}", args["path"].as_str().unwrap_or(".")) } - _ => format!("Calling {}", call.name), + other => format!("Calling {}", other), } } - /// Generate a commit message with optional tool-calling support. + /// Generate a commit message from a diff string and file summary. + pub fn generate_commit_message( + &mut self, + diff: String, + summary: String, + repo_path: PathBuf, + cx: &mut Context, + ) -> Option { + self.generate_commit_message_with_tools(diff, summary, repo_path, false, cx) + } + + /// Generate a commit message, optionally letting the model call tools. + /// + /// Returns the [`GenerationId`] that was started, or `None` when the + /// request was refused — already generating, or inside the cooldown. The + /// caller does not need to guard those cases itself, which is what keeps + /// the button, Ctrl+G and the command palette from disagreeing. pub fn generate_commit_message_with_tools( &mut self, diff: String, @@ -155,942 +220,673 @@ impl AiGenerator { repo_path: PathBuf, use_tools: bool, cx: &mut Context, - ) -> Task> { - if let Some(last_time) = self.last_request_time { - let elapsed = last_time.elapsed(); - if elapsed < MIN_REQUEST_INTERVAL { - let wait = MIN_REQUEST_INTERVAL - elapsed; - let err_msg = format!("Rate limited. Please wait {:.0}s.", wait.as_secs_f32()); - self.last_error = Some(err_msg.clone()); - cx.emit(AiEvent::GenerationFailed(err_msg.clone())); - cx.notify(); - return cx.spawn(async move |_: WeakEntity, _: &mut AsyncApp| { - Err(anyhow::anyhow!(err_msg)) - }); - } + ) -> Option { + if self.is_generating() { + return None; + } + if let Some(wait) = self.cooldown_remaining() { + // Informational, and deliberately not a failure: reporting this as + // `GenerationFailed` used to clear the spinner of a request that + // was still running. + cx.emit(AiEvent::RateLimited { wait }); + return None; } - - self.is_generating = true; - self.last_error = None; - self.last_request_time = Some(Instant::now()); - cx.emit(AiEvent::GenerationStarted); - cx.notify(); let settings_state = cx.global::(); - let settings = settings_state.settings().clone(); + let settings = settings_state.settings(); + let provider = settings.ai.provider; let api_key = settings_state.ai_api_key(); let model = settings.ai.model.clone(); - let provider = settings.ai.provider.clone(); - let commit_style = settings - .ai - .commit_style - .parse::() - .unwrap_or_default(); + let commit_style = CommitStyle::from_id(&settings.ai.commit_style).unwrap_or_default(); let inject_project_context = settings.ai.inject_project_context; + let base_url_override = settings.ai.base_url_override.clone(); + let openrouter_attribution = settings.ai.openrouter_attribution; - let client = cx.http_client(); + self.next_sequence = self.next_sequence.wrapping_add(1); + let id = GenerationId { + sequence: self.next_sequence, + repo_path: repo_path.clone(), + }; + self.active = Some(id.clone()); + cx.emit(AiEvent::GenerationStarted(id.clone())); + cx.notify(); - cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { - let project_context = if inject_project_context { - let repo_path_for_context = repo_path.clone(); - cx.background_executor() - .spawn(async move { collect_project_context(&repo_path_for_context) }) - .await - } else { - None - }; - let ctx_ref = project_context.as_deref(); - // Callback for tool call status — emits events to update the status bar. - let mut on_tool_call = |desc: &str| { - let msg = desc.to_string(); - let _ = this.update(cx, |_this, cx| { - cx.emit(AiEvent::ToolCallStarted(msg)); - cx.notify(); - }); - }; - let req = GenerateRequest { - client: &client, - api_key: &api_key, - model: &model, - diff: &diff, - summary: &summary, - commit_style, - project_context: ctx_ref, - }; - let result = match provider.as_str() { - "gemini" => { - if use_tools { - let mut tc = ToolContext { - repo_path: &repo_path, - on_status: &mut on_tool_call, - }; - generate_gemini_with_tools(&req, &mut tc).await - } else { - generate_gemini(&req).await - } - } - "openai" => { - if use_tools { - let mut tc = ToolContext { - repo_path: &repo_path, - on_status: &mut on_tool_call, - }; - generate_openai_with_tools(&req, &mut tc).await - } else { - generate_openai(&req).await + let client = cx.http_client(); + let (status_tx, mut status_rx) = mpsc::unbounded::(); + + let task_id = id.clone(); + self.task = Some( + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + // Drain tool status onto the foreground while the request runs. + // This is the only reason a foreground task exists at all. + let status_id = task_id.clone(); + let status_this = this.clone(); + let status_pump = cx.spawn(async move |cx: &mut AsyncApp| { + while let Some(description) = status_rx.next().await { + let id = status_id.clone(); + let _ = status_this.update(cx, |_this, cx| { + cx.emit(AiEvent::ToolCallStarted(id, description)); + cx.notify(); + }); } - } - "deepseek" => { - if use_tools { - let mut tc = ToolContext { - repo_path: &repo_path, - on_status: &mut on_tool_call, + }); + + let background_repo_path = task_id.repo_path.clone(); + let executor = cx.background_executor().clone(); + let result = executor + .clone() + .spawn(async move { + let project_context = if inject_project_context { + collect_project_context(&background_repo_path) + } else { + None }; - generate_deepseek_with_tools(&req, &mut tc).await - } else { - generate_deepseek(&req).await - } - } - "anthropic" => { - if use_tools { - let mut tc = ToolContext { - repo_path: &repo_path, - on_status: &mut on_tool_call, + let request = GenerateRequest { + client, + executor, + provider, + api_key, + model, + prompt: build_prompt( + &diff, + &summary, + commit_style, + project_context.as_deref(), + use_tools, + ), + repo_path: background_repo_path, + use_tools, + base_url_override, + openrouter_attribution, }; - generate_anthropic_with_tools(&req, &mut tc).await - } else { - generate_anthropic(&req).await + dispatch(&request, &status_tx).await + }) + .await; + + // The sender is dropped with the background task, so the pump ends + // on its own; awaiting it just keeps the task alive until then. + status_pump.await; + + // `let _` rather than `?`: a released entity is not a generation + // failure, and conflating the two makes "the window closed" + // indistinguishable from a real error for any future caller. + let _ = this.update(cx, |this, cx| { + // A superseded generation must not clear the state of the one + // that replaced it. + if this.active.as_ref() != Some(&task_id) { + return; } - } - other => Err(anyhow::anyhow!("Unknown AI provider: {}", other)), - }; - - cx.update(|cx| { - this.update(cx, |this, cx| { - this.is_generating = false; - match &result { - Ok(msg) => { - this.last_result = Some(msg.clone()); - cx.emit(AiEvent::GenerationCompleted(msg.clone())); - } - Err(e) => { - let err = e.to_string(); - this.last_error = Some(err.clone()); - cx.emit(AiEvent::GenerationFailed(err)); + this.active = None; + this.last_request_finished = Some(Instant::now()); + match result { + Ok(message) => { + cx.emit(AiEvent::GenerationCompleted(task_id.clone(), message)) } + Err(error) => cx.emit(AiEvent::GenerationFailed( + task_id.clone(), + describe_error(&error), + )), } cx.notify(); - }) - })?; + }); + }), + ); - result - }) + Some(id) } } -async fn read_response_body(response: &mut Response) -> Result> { - let mut body = Vec::new(); - response - .body_mut() - .read_to_end(&mut body) - .await - .context("Failed to read response body")?; - Ok(body) +/// Turn an `anyhow` chain into the single actionable sentence the user sees. +/// +/// The toast used to carry the whole context chain, which is precise and +/// unreadable. The provider layer already writes actionable sentences; this +/// just picks the outermost one and drops the plumbing. +fn describe_error(error: &anyhow::Error) -> String { + error.to_string() } -/// Generate a commit message using the Gemini API. -async fn generate_gemini(req: &GenerateRequest<'_>) -> Result { - let api_key = req - .api_key - .as_ref() - .context("Gemini API key not configured. Set it in Settings > AI.")?; - - let prompt = build_prompt(req.diff, req.summary, req.commit_style, req.project_context); - - let url = format!( - "https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}", - req.model, api_key - ); +// ============================================================================ +// Dispatch +// ============================================================================ - let json_body = serde_json::json!({ - "contents": [{ - "parts": [{ - "text": prompt - }] - }], - "generationConfig": { - "temperature": 0.3, - "maxOutputTokens": 2048, - "topP": 0.8 +/// Route to the right provider family. Three arms rather than the eight +/// near-identical ones this replaced: DeepSeek and OpenRouter are the OpenAI +/// shape with a different URL and, for OpenRouter, two optional headers. +async fn dispatch(req: &GenerateRequest, status: &StatusSender) -> Result { + match req.provider { + AiProvider::Gemini => generate_gemini(req, status).await, + AiProvider::Anthropic => generate_anthropic(req, status).await, + other => { + let endpoint = + openai_compat_endpoint(other, &req.base_url_override, req.openrouter_attribution); + generate_openai_compatible(req, &endpoint, status).await } - }); - - let body_bytes = serde_json::to_vec(&json_body)?; - let request = Request::builder() - .method(Method::POST) - .uri(&url) - .header("Content-Type", "application/json") - .body(AsyncBody::from(body_bytes)) - .context("Failed to build Gemini request")?; - - let mut response = req - .client - .send(request) - .await - .context("Failed to send request to Gemini API")?; - - let status = response.status(); - let body = read_response_body(&mut response).await?; - - if !status.is_success() { - let text = String::from_utf8_lossy(&body); - anyhow::bail!("Gemini API error ({}): {}", status, text); } - - let json: GeminiResponse = - serde_json::from_slice(&body).context("Failed to parse Gemini response")?; - - let text = json - .candidates - .first() - .and_then(|c| c.content.parts.first()) - .map(|p| p.text.trim().to_string()) - .context("No text in Gemini response")?; - - Ok(text) -} - -/// Generate a commit message using the OpenAI API. -async fn generate_openai(req: &GenerateRequest<'_>) -> Result { - generate_openai_compatible(req, "OpenAI", "https://api.openai.com/v1/chat/completions").await } -/// Generate a commit message using DeepSeek's OpenAI-compatible API. -async fn generate_deepseek(req: &GenerateRequest<'_>) -> Result { - generate_openai_compatible(req, "DeepSeek", "https://api.deepseek.com/chat/completions").await +fn api_key(req: &GenerateRequest) -> Result<&str> { + req.api_key + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()) + .with_context(|| { + format!( + "No {} API key. Add one in Settings > AI.", + req.provider.display_name() + ) + }) } -async fn generate_openai_compatible( - req: &GenerateRequest<'_>, - provider: &str, - endpoint: &str, -) -> Result { - let api_key = req.api_key.as_ref().with_context(|| { - format!( - "{} API key not configured. Set it in Settings > AI.", - provider - ) - })?; - - let prompt = build_prompt(req.diff, req.summary, req.commit_style, req.project_context); - - let json_body = serde_json::json!({ - "model": req.model, - "messages": [{ - "role": "user", - "content": prompt - }], - "temperature": 0.3, - "max_tokens": 2048 - }); - - let body_bytes = serde_json::to_vec(&json_body)?; - let request = Request::builder() - .method(Method::POST) - .uri(endpoint) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") - .body(AsyncBody::from(body_bytes)) - .with_context(|| format!("Failed to build {} request", provider))?; - - let mut response = req - .client - .send(request) - .await - .with_context(|| format!("Failed to send request to {} API", provider))?; - - let status = response.status(); - let body = read_response_body(&mut response).await?; - - if !status.is_success() { - let text = String::from_utf8_lossy(&body); - anyhow::bail!("{} API error ({}): {}", provider, status, text); - } - - let json: serde_json::Value = serde_json::from_slice(&body) - .with_context(|| format!("Failed to parse {} response", provider))?; - let text = json["choices"][0]["message"]["content"] - .as_str() - .with_context(|| format!("No text in {} response", provider))? - .trim() - .to_string(); - - Ok(text) -} +/// Send one request, retrying rate limits and transient server errors with +/// backoff that honours `Retry-After`. +/// +/// Returns the parsed JSON body. Every non-success status that survives the +/// retries is reported as a sentence the user can act on. +async fn send_json( + req: &GenerateRequest, + url: &str, + extra_headers: &[(&str, &str)], + body: &Value, +) -> Result { + let key = api_key(req)?; + let body_bytes = serde_json::to_vec(body)?; + + for attempt in 0..=MAX_RETRIES { + let mut builder = Request::builder() + .method(Method::POST) + .uri(url) + .header("Content-Type", "application/json") + // Without a deadline a provider that accepts the connection and + // then stalls leaves the spinner running until the app restarts. + .timeout(REQUEST_TIMEOUT); + + builder = match auth_style(req.provider) { + AuthStyle::Bearer => builder.header("Authorization", format!("Bearer {key}")), + AuthStyle::AnthropicHeader => builder + .header("x-api-key", key) + .header("anthropic-version", ANTHROPIC_VERSION), + // A header, not `?key=`: query strings land in proxy logs, + // TLS-inspecting middleboxes, and any error path that prints a URL. + AuthStyle::GoogleHeader => builder.header("x-goog-api-key", key), + }; + for (name, value) in extra_headers { + builder = builder.header(*name, *value); + } -/// Generate a commit message using the Anthropic API. -async fn generate_anthropic(req: &GenerateRequest<'_>) -> Result { - let api_key = req - .api_key - .as_ref() - .context("Anthropic API key not configured. Set it in Settings > AI.")?; - - let prompt = build_prompt(req.diff, req.summary, req.commit_style, req.project_context); - - let json_body = serde_json::json!({ - "model": req.model, - "max_tokens": 2048, - "messages": [{ - "role": "user", - "content": prompt - }] - }); - - let body_bytes = serde_json::to_vec(&json_body)?; - let request = Request::builder() - .method(Method::POST) - .uri("https://api.anthropic.com/v1/messages") - .header("x-api-key", api_key.as_str()) - .header("anthropic-version", "2023-06-01") - .header("Content-Type", "application/json") - .body(AsyncBody::from(body_bytes)) - .context("Failed to build Anthropic request")?; - - let mut response = req - .client - .send(request) - .await - .context("Failed to send request to Anthropic API")?; - - let status = response.status(); - let body = read_response_body(&mut response).await?; - - if !status.is_success() { - let text = String::from_utf8_lossy(&body); - anyhow::bail!("Anthropic API error ({}): {}", status, text); - } + let request = builder + .body(AsyncBody::from(body_bytes.clone())) + .with_context(|| { + format!( + "Couldn't build the {} request.", + req.provider.display_name() + ) + })?; - let json: serde_json::Value = - serde_json::from_slice(&body).context("Failed to parse Anthropic response")?; - let text = json["content"][0]["text"] - .as_str() - .context("No text in Anthropic response")? - .trim() - .to_string(); + let mut response = req.client.send(request).await.with_context(|| { + format!( + "Couldn't reach {}. Check your connection or proxy.", + provider_host(req) + ) + })?; - Ok(text) -} + let status = response.status(); + let retry_after = parse_retry_after( + response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()), + ); + let raw = read_response_body(&mut response).await?; -fn build_prompt( - diff: &str, - summary: &str, - commit_style: CommitStyle, - project_context: Option<&str>, -) -> String { - let style_instruction = match commit_style { - CommitStyle::Conventional => { - "Use the Conventional Commits format: (): \n\ - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore\n\ - Keep the first line under 72 characters.\n\ - Add a blank line then a detailed body that explains what changed and why.\n\ - List the key changes as bullet points if there are multiple distinct changes." - } - CommitStyle::Descriptive => { - "Write a clear, descriptive commit message.\n\ - First line: imperative mood summary under 72 characters.\n\ - Add a blank line then a detailed body that explains what changed and why.\n\ - List the key changes as bullet points if there are multiple distinct changes." - } - CommitStyle::Brief => { - "Write a concise commit message in imperative mood.\n\ - Keep it to a single line under 72 characters." + if status.is_success() { + return serde_json::from_slice(&raw).with_context(|| { + format!("Couldn't read {}'s response.", req.provider.display_name()) + }); } - }; - - let max_diff_len = 200_000; - let diff_text = if diff.len() > max_diff_len { - let truncated = crate::tools::safe_truncate(diff, max_diff_len); - let truncation_point = truncated.rfind('\n').unwrap_or(truncated.len()); - format!( - "{}\n\n[diff truncated -- showing {}/{} bytes]", - &diff[..truncation_point], - truncation_point, - diff.len() - ) - } else { - diff.to_string() - }; - - let context_section = match project_context { - Some(context) => format!("Project Context:\n{context}\n\n"), - None => String::new(), - }; - - format!( - "You are a Git commit message generator. Generate ONLY the commit message, nothing else.\n\ - No markdown formatting, no code blocks, no explanations.\n\n\ - {style_instruction}\n\n\ - {context_section}\ - Files changed:\n{summary}\n\n\ - Diff:\n{diff_text}" - ) -} -const PROJECT_CONTEXT_FILES: &[&str] = &["README.md", "CLAUDE.md", "AGENTS.md"]; -const MAX_PROJECT_CONTEXT_BYTES: usize = 50_000; - -fn collect_project_context(repo_path: &Path) -> Option { - let mut combined = String::new(); - - for filename in PROJECT_CONTEXT_FILES { - let file_path = repo_path.join(filename); - if let Ok(contents) = std::fs::read_to_string(&file_path) { - if !contents.trim().is_empty() { - combined.push_str(&format!("=== {filename} ===\n{contents}\n\n")); - } + if is_retryable(status.as_u16()) && attempt < MAX_RETRIES { + let delay = retry_delay(attempt, retry_after); + log::warn!( + "{} returned {}; retrying in {:?}", + req.provider.display_name(), + status, + delay + ); + req.executor.timer(delay).await; + continue; } - } - if combined.is_empty() { - return None; + return Err(status_error(req, status.as_u16(), &raw)); } - if combined.len() > MAX_PROJECT_CONTEXT_BYTES { - let truncation_point = { - let safe = crate::tools::safe_truncate(&combined, MAX_PROJECT_CONTEXT_BYTES); - safe.rfind('\n').unwrap_or(safe.len()) - }; - combined.truncate(truncation_point); - combined.push_str("\n\n[project context truncated]"); - } - - Some(combined) + unreachable!("the retry loop always returns on its final attempt") } -#[derive(Debug, Deserialize)] -struct GeminiResponse { - candidates: Vec, +fn provider_host(req: &GenerateRequest) -> String { + effective_host(req.provider, &req.base_url_override) } -#[derive(Debug, Deserialize)] -struct GeminiCandidate { - content: GeminiContent, +/// Map a failing status onto the sentence that tells the user what to do. +fn status_error(req: &GenerateRequest, status: u16, body: &[u8]) -> anyhow::Error { + let name = req.provider.display_name(); + match status { + 401 | 403 => { + anyhow::anyhow!("{name} did not accept this API key. Check it in Settings > AI.") + } + // OpenRouter answers 404 when no live endpoint for the model supports + // tool use. That is an opaque failure unless it is named. + 404 if req.provider == AiProvider::OpenRouter && req.use_tools => anyhow::anyhow!( + "{} does not support tool calling. Turn off \"Let the model read files\" in \ + Settings > AI, or pick a model marked Tools in the model list.", + req.model + ), + 404 => anyhow::anyhow!( + "{} isn't available on this key. Pick another model or refresh the list.", + req.model + ), + 429 => anyhow::anyhow!("Rate limited by {name}. This usually clears in a minute."), + 500..=599 => anyhow::anyhow!("{name} is having trouble ({status}). Try again shortly."), + _ => { + let detail = provider_error_message(body) + .unwrap_or_else(|| String::from_utf8_lossy(body).chars().take(300).collect()); + anyhow::anyhow!("{name} rejected the request ({status}): {detail}") + } + } } -#[derive(Debug, Deserialize)] -struct GeminiContent { - parts: Vec, +/// Pull the provider's own error sentence out of a body, whatever shape it +/// arrived in. Also covers the case where a gateway answers HTTP 200 with a +/// top-level `error` object. +fn provider_error_message(body: &[u8]) -> Option { + let json: Value = serde_json::from_slice(body).ok()?; + error_message_in(&json) } -#[derive(Debug, Deserialize)] -struct GeminiPart { - text: String, +fn error_message_in(json: &Value) -> Option { + json["error"]["message"] + .as_str() + .or_else(|| json["error"]["msg"].as_str()) + .or_else(|| json["error"].as_str()) + .or_else(|| json["message"].as_str()) + .map(|message| message.trim().to_string()) + .filter(|message| !message.is_empty()) } // ============================================================================ -// Tool-calling versions of generation functions +// OpenAI-compatible family: OpenAI, DeepSeek, OpenRouter // ============================================================================ -/// Maximum number of tool-calling iterations to prevent infinite loops. -const MAX_TOOL_ITERATIONS: usize = 3; - -/// Generate a commit message using Anthropic's API with tool-calling support. -async fn generate_anthropic_with_tools( - req: &GenerateRequest<'_>, - tc: &mut ToolContext<'_>, +async fn generate_openai_compatible( + req: &GenerateRequest, + endpoint: &OpenAiCompatEndpoint, + status: &StatusSender, ) -> Result { - let api_key = req - .api_key - .as_ref() - .context("Anthropic API key not configured. Set it in Settings > AI.")?; - - let system_prompt = - build_tool_prompt(req.diff, req.summary, req.commit_style, req.project_context); - let tools = anthropic_tool_definitions(); - let mut messages: Vec = vec![]; + let tools = req + .use_tools + .then(|| Value::Array(openai_tool_definitions())); + let mut history: Vec = Vec::new(); + let mut budget = ToolBudget::new(); for _ in 0..MAX_TOOL_ITERATIONS { - let json_body = serde_json::json!({ - "model": req.model, - "max_tokens": 2048, - "system": system_prompt, - "messages": messages.clone(), - "tools": tools, - }); - - let body_bytes = serde_json::to_vec(&json_body)?; - let request = Request::builder() - .method(Method::POST) - .uri("https://api.anthropic.com/v1/messages") - .header("x-api-key", api_key.as_str()) - .header("anthropic-version", "2023-06-01") - .header("Content-Type", "application/json") - .body(AsyncBody::from(body_bytes)) - .context("Failed to build Anthropic request")?; - - let mut response = req - .client - .send(request) - .await - .context("Failed to send request to Anthropic API")?; - - let status = response.status(); - let body = read_response_body(&mut response).await?; + let body = build_request_body( + endpoint.provider, + &req.model, + &req.prompt, + tools.as_ref(), + &history, + ); + let json = send_json(req, &endpoint.url, &endpoint.extra_headers, &body).await?; - if !status.is_success() { - let text = String::from_utf8_lossy(&body); - anyhow::bail!("Anthropic API error ({}): {}", status, text); + // A gateway can answer 200 with an error object; without this the + // failure surfaces as the useless "No text in ... response". + if let Some(message) = error_message_in(&json) { + anyhow::bail!("{} reported: {}", endpoint.provider.display_name(), message); } - let json: serde_json::Value = - serde_json::from_slice(&body).context("Failed to parse Anthropic response")?; + let message = &json["choices"][0]["message"]; + let finish_reason = json["choices"][0]["finish_reason"].as_str().unwrap_or(""); + history.push(message.clone()); - // Check for stop reason - let stop_reason = json["stop_reason"].as_str().unwrap_or(""); + let tool_calls: Vec = message["tool_calls"] + .as_array() + .cloned() + .unwrap_or_default(); - if stop_reason == "end_turn" { - // Extract text content - if let Some(content) = json["content"].as_array() { - for block in content { - if block["type"].as_str() == Some("text") { - if let Some(text) = block["text"].as_str() { - return Ok(text.trim().to_string()); - } - } - } + if finish_reason == "tool_calls" || !tool_calls.is_empty() { + // A `tool_calls` finish reason with no array used to push the + // assistant turn and loop until the iteration cap, then report the + // generic "Max tool iterations reached". + if tool_calls.is_empty() { + anyhow::bail!( + "{} asked to call a tool but sent no tool call. Try turning off \ + \"Let the model read files\" in Settings > AI.", + endpoint.provider.display_name() + ); } - anyhow::bail!("No text in Anthropic response"); - } - - if stop_reason == "tool_use" { - // Extract tool calls and execute them - let mut tool_results = vec![]; - let mut assistant_content: Vec = vec![]; - - if let Some(content) = json["content"].as_array() { - for block in content { - assistant_content.push(block.clone()); - - if block["type"].as_str() == Some("tool_use") { - let tool_call = ToolCall { - id: block["id"].as_str().unwrap_or_default().to_string(), - name: block["name"].as_str().unwrap_or_default().to_string(), - arguments: block["input"].clone(), - }; - - (tc.on_status)(&AiGenerator::describe_tool_call(&tool_call)); - let result = execute_tool(&tool_call, tc.repo_path); - let result_content = match result.result { - Ok(output) => output, - Err(e) => format!("Error: {}", e), - }; - - tool_results.push(serde_json::json!({ - "type": "tool_result", - "tool_use_id": result.call_id, - "content": result_content - })); - } - } + for call in &tool_calls { + let function = &call["function"]; + let tool_call = ToolCall { + id: call["id"].as_str().unwrap_or_default().to_string(), + name: function["name"].as_str().unwrap_or_default().to_string(), + arguments: serde_json::from_str(function["arguments"].as_str().unwrap_or("{}")) + .unwrap_or_else(|_| serde_json::json!({})), + }; + let result = run_tool(&tool_call, &req.repo_path, &mut budget, status); + history.push(serde_json::json!({ + "role": "tool", + "tool_call_id": result.call_id, + "content": tool_output(&result), + })); } - - // Add assistant message with tool calls - messages.push(serde_json::json!({ - "role": "assistant", - "content": assistant_content - })); - - // Add user message with tool results - messages.push(serde_json::json!({ - "role": "user", - "content": tool_results - })); - continue; } - // Unknown stop reason - try to extract text anyway - if let Some(content) = json["content"].as_array() { - for block in content { - if block["type"].as_str() == Some("text") { - if let Some(text) = block["text"].as_str() { - return Ok(text.trim().to_string()); - } - } + if let Some(content) = message["content"].as_str() { + let trimmed = content.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); } } - anyhow::bail!("Unexpected Anthropic response: stop_reason={}", stop_reason); - } + if finish_reason == "length" { + anyhow::bail!( + "The response hit the output token limit before finishing. Try a shorter diff." + ); + } + if finish_reason == "content_filter" { + anyhow::bail!( + "{} filtered this response. Try a different model.", + endpoint.provider.display_name() + ); + } - anyhow::bail!("Max tool iterations reached without generating commit message") -} + anyhow::bail!( + "{} returned no commit message (finish_reason={}).", + endpoint.provider.display_name(), + if finish_reason.is_empty() { + "unset" + } else { + finish_reason + } + ); + } -/// Generate a commit message using OpenAI's API with function calling support. -async fn generate_openai_with_tools( - req: &GenerateRequest<'_>, - tc: &mut ToolContext<'_>, -) -> Result { - generate_openai_compatible_with_tools( - req, - tc, - "OpenAI", - "https://api.openai.com/v1/chat/completions", - ) - .await + Err(iterations_exhausted()) } -async fn generate_deepseek_with_tools( - req: &GenerateRequest<'_>, - tc: &mut ToolContext<'_>, -) -> Result { - generate_openai_compatible_with_tools( - req, - tc, - "DeepSeek", - "https://api.deepseek.com/chat/completions", - ) - .await -} +// ============================================================================ +// Anthropic +// ============================================================================ -async fn generate_openai_compatible_with_tools( - req: &GenerateRequest<'_>, - tc: &mut ToolContext<'_>, - provider: &str, - endpoint: &str, -) -> Result { - let api_key = req.api_key.as_ref().with_context(|| { - format!( - "{} API key not configured. Set it in Settings > AI.", - provider - ) - })?; - - let system_prompt = - build_tool_prompt(req.diff, req.summary, req.commit_style, req.project_context); - let tools = openai_tool_definitions(); - let mut messages: Vec = vec![ - serde_json::json!({ - "role": "system", - "content": system_prompt - }), - serde_json::json!({ - "role": "user", - "content": "Generate a commit message for these changes." - }), - ]; +async fn generate_anthropic(req: &GenerateRequest, status: &StatusSender) -> Result { + let tools = req + .use_tools + .then(|| Value::Array(anthropic_tool_definitions())); + let mut history: Vec = Vec::new(); + let mut budget = ToolBudget::new(); for _ in 0..MAX_TOOL_ITERATIONS { - let json_body = serde_json::json!({ - "model": req.model, - "messages": messages.clone(), - "tools": tools, - "tool_choice": "auto", - "temperature": 0.3, - "max_tokens": 2048 - }); - - let body_bytes = serde_json::to_vec(&json_body)?; - let request = Request::builder() - .method(Method::POST) - .uri(endpoint) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") - .body(AsyncBody::from(body_bytes)) - .with_context(|| format!("Failed to build {} request", provider))?; - - let mut response = req - .client - .send(request) - .await - .with_context(|| format!("Failed to send request to {} API", provider))?; - - let status = response.status(); - let body = read_response_body(&mut response).await?; - - if !status.is_success() { - let text = String::from_utf8_lossy(&body); - anyhow::bail!("{} API error ({}): {}", provider, status, text); - } - - let json: serde_json::Value = serde_json::from_slice(&body) - .with_context(|| format!("Failed to parse {} response", provider))?; - - let message = &json["choices"][0]["message"]; - let finish_reason = json["choices"][0]["finish_reason"].as_str().unwrap_or(""); + // `build_request_body` seeds the opening user turn. Sending + // `messages: []` is a 400, which is why this provider had never once + // produced a commit message. + let body = build_request_body( + AiProvider::Anthropic, + &req.model, + &req.prompt, + tools.as_ref(), + &history, + ); + let json = send_json(req, ANTHROPIC_ENDPOINT, &[], &body).await?; - // Add assistant message to history - messages.push(message.clone()); + let stop_reason = json["stop_reason"].as_str().unwrap_or(""); + let content = json["content"].as_array().cloned().unwrap_or_default(); - if finish_reason == "stop" { - if let Some(content) = message["content"].as_str() { - return Ok(content.trim().to_string()); + if stop_reason == "tool_use" { + let mut results: Vec = Vec::new(); + for block in &content { + if block["type"].as_str() != Some("tool_use") { + continue; + } + let tool_call = ToolCall { + id: block["id"].as_str().unwrap_or_default().to_string(), + name: block["name"].as_str().unwrap_or_default().to_string(), + arguments: block["input"].clone(), + }; + let result = run_tool(&tool_call, &req.repo_path, &mut budget, status); + results.push(serde_json::json!({ + "type": "tool_result", + "tool_use_id": result.call_id, + "content": tool_output(&result), + })); } - anyhow::bail!("No text in {} response", provider); - } - if finish_reason == "tool_calls" { - if let Some(tool_calls) = message["tool_calls"].as_array() { - for tool_call in tool_calls { - let function = &tool_call["function"]; - let tool_call_obj = ToolCall { - id: tool_call["id"].as_str().unwrap_or_default().to_string(), - name: function["name"].as_str().unwrap_or_default().to_string(), - arguments: serde_json::from_str( - function["arguments"].as_str().unwrap_or("{}"), - ) - .unwrap_or(serde_json::json!({})), - }; - - (tc.on_status)(&AiGenerator::describe_tool_call(&tool_call_obj)); - let result = execute_tool(&tool_call_obj, tc.repo_path); - let result_content = match result.result { - Ok(output) => output, - Err(e) => format!("Error: {}", e), - }; - - messages.push(serde_json::json!({ - "role": "tool", - "tool_call_id": result.call_id, - "content": result_content - })); - } + if results.is_empty() { + anyhow::bail!( + "Anthropic asked to call a tool but sent no tool call. Try turning off \ + \"Let the model read files\" in Settings > AI." + ); } + + history.push(serde_json::json!({ "role": "assistant", "content": content })); + history.push(serde_json::json!({ "role": "user", "content": results })); continue; } - // Try to extract content anyway - if let Some(content) = message["content"].as_str() { - return Ok(content.trim().to_string()); + if let Some(text) = first_text_block(&content) { + return Ok(text); + } + + if stop_reason == "max_tokens" { + anyhow::bail!( + "The response hit the output token limit before finishing. Try a shorter diff." + ); } anyhow::bail!( - "Unexpected {} response: finish_reason={}", - provider, - finish_reason + "Anthropic returned no commit message (stop_reason={}).", + if stop_reason.is_empty() { + "unset" + } else { + stop_reason + } ); } - anyhow::bail!("Max tool iterations reached without generating commit message") + Err(iterations_exhausted()) } -/// Generate a commit message using Gemini's API with function calling support. -async fn generate_gemini_with_tools( - req: &GenerateRequest<'_>, - tc: &mut ToolContext<'_>, -) -> Result { - let api_key = req - .api_key - .as_ref() - .context("Gemini API key not configured. Set it in Settings > AI.")?; - - let prompt = build_tool_prompt(req.diff, req.summary, req.commit_style, req.project_context); - let tools = vec![gemini_tool_definitions()]; +fn first_text_block(content: &[Value]) -> Option { + content + .iter() + .filter(|block| block["type"].as_str() == Some("text")) + .filter_map(|block| block["text"].as_str()) + .map(str::trim) + .find(|text| !text.is_empty()) + .map(str::to_string) +} - let url = format!( - "https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}", - req.model, api_key - ); +// ============================================================================ +// Gemini +// ============================================================================ - let mut contents = vec![serde_json::json!({ - "role": "user", - "parts": [{ "text": prompt }] - })]; +async fn generate_gemini(req: &GenerateRequest, status: &StatusSender) -> Result { + let tools = req.use_tools.then(gemini_tool_definitions); + let url = gemini_endpoint(&req.model); + let mut history: Vec = Vec::new(); + let mut budget = ToolBudget::new(); for _ in 0..MAX_TOOL_ITERATIONS { - let json_body = serde_json::json!({ - "contents": contents.clone(), - "tools": tools.clone(), - "generationConfig": { - "temperature": 0.3, - "maxOutputTokens": 2048, - "topP": 0.8 - } - }); - - let body_bytes = serde_json::to_vec(&json_body)?; - let request = Request::builder() - .method(Method::POST) - .uri(&url) - .header("Content-Type", "application/json") - .body(AsyncBody::from(body_bytes)) - .context("Failed to build Gemini request")?; - - let mut response = req - .client - .send(request) - .await - .context("Failed to send request to Gemini API")?; - - let status = response.status(); - let body = read_response_body(&mut response).await?; - - if !status.is_success() { - let text = String::from_utf8_lossy(&body); - anyhow::bail!("Gemini API error ({}): {}", status, text); + let body = build_request_body( + AiProvider::Gemini, + &req.model, + &req.prompt, + tools.as_ref(), + &history, + ); + let raw = send_json(req, &url, &[], &body).await?; + let json: GeminiResponse = + serde_json::from_value(raw).context("Couldn't read Gemini's response.")?; + + if let Some(reason) = json + .prompt_feedback + .as_ref() + .and_then(|feedback| feedback.block_reason.as_deref()) + { + anyhow::bail!("Gemini blocked this prompt ({reason})."); } - let json: GeminiToolResponse = - serde_json::from_slice(&body).context("Failed to parse Gemini response")?; - let candidate = json .candidates .first() - .context("No candidates in Gemini response")?; - - // Check for function call - if let Some(part) = candidate.content.parts.first() { - if let Some(fc) = &part.function_call { - let tool_call = ToolCall { - id: format!("gemini_{}", uuid::Uuid::new_v4()), - name: fc.name.clone(), - arguments: fc.args.clone(), - }; - - (tc.on_status)(&AiGenerator::describe_tool_call(&tool_call)); - let result = execute_tool(&tool_call, tc.repo_path); - let result_content = match result.result { - Ok(output) => output, - Err(e) => format!("Error: {}", e), - }; - - // Echo the model's function call with its thoughtSignature. - // The signature must be returned verbatim so the model retains - // reasoning continuity across tool-call round trips. - let sig = part.thought_signature.clone(); - let model_part: serde_json::Value = if let Some(ref s) = sig { - serde_json::json!({ - "functionCall": { - "name": fc.name, - "args": fc.args - }, - "thoughtSignature": s - }) - } else { - serde_json::json!({ - "functionCall": { - "name": fc.name, - "args": fc.args - } - }) - }; - contents.push(serde_json::json!({ - "role": "model", - "parts": [model_part] - })); - - // Add function response - contents.push(serde_json::json!({ - "role": "function", - "parts": [{ - "functionResponse": { - "name": fc.name, - "response": { "content": result_content } - } - }] - })); + .context("Gemini returned no candidates.")?; + let parts = candidate + .content + .as_ref() + .map(|content| content.parts.as_slice()) + .unwrap_or_default(); + // Iterate every part. Gemini routinely returns a thought part followed + // by a `functionCall`, or several parallel calls in one turn; taking + // only the first dropped the rest and fell through to a parse error + // whenever a thought part came first. + let mut model_parts: Vec = Vec::new(); + let mut response_parts: Vec = Vec::new(); + for part in parts { + let Some(call) = &part.function_call else { continue; + }; + let tool_call = ToolCall { + id: format!("gemini_{}", model_parts.len()), + name: call.name.clone(), + arguments: call.args.clone(), + }; + let result = run_tool(&tool_call, &req.repo_path, &mut budget, status); + + // The thought signature must be echoed back verbatim so the model + // retains reasoning continuity across the round trip. + let mut model_part = serde_json::json!({ + "functionCall": { "name": call.name, "args": call.args } + }); + if let Some(signature) = &part.thought_signature { + model_part["thoughtSignature"] = Value::String(signature.clone()); } + model_parts.push(model_part); - // Text response - if !part.text.is_empty() { - return Ok(part.text.trim().to_string()); - } + response_parts.push(serde_json::json!({ + "functionResponse": { + "name": call.name, + "response": { "content": tool_output(&result) } + } + })); + } + + if !model_parts.is_empty() { + history.push(serde_json::json!({ "role": "model", "parts": model_parts })); + // The current REST API expects function results on a `user` turn; + // `role: "function"` is a legacy spelling from other SDKs. + history.push(serde_json::json!({ "role": "user", "parts": response_parts })); + continue; } - anyhow::bail!("Unexpected Gemini response format"); + let text: String = parts + .iter() + .filter_map(|part| part.text.as_deref()) + .collect::>() + .join(""); + let text = text.trim(); + if !text.is_empty() { + return Ok(text.to_string()); + } + + // Everything below used to surface as the same "Failed to parse Gemini + // response", which told the user nothing about what to do. + match candidate.finish_reason.as_deref() { + Some("MAX_TOKENS") => anyhow::bail!( + "The response hit the output token limit before finishing. Try a shorter diff." + ), + Some("SAFETY") => anyhow::bail!("Gemini blocked this response (SAFETY)."), + Some("RECITATION") => anyhow::bail!("Gemini blocked this response (RECITATION)."), + Some(other) => anyhow::bail!("Gemini returned no commit message ({other})."), + None => anyhow::bail!("Gemini returned no commit message."), + } } - anyhow::bail!("Max tool iterations reached without generating commit message") + Err(iterations_exhausted()) } -/// Build a prompt that encourages tool usage when appropriate. -fn build_tool_prompt( - diff: &str, - summary: &str, - commit_style: CommitStyle, - project_context: Option<&str>, -) -> String { - let style_instruction = match commit_style { - CommitStyle::Conventional => { - "Use the Conventional Commits format: (): \n\ - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore\n\ - Keep the first line under 72 characters.\n\ - Add a blank line then a detailed body that explains what changed and why.\n\ - List the key changes as bullet points if there are multiple distinct changes." - } - CommitStyle::Descriptive => { - "Write a clear, descriptive commit message.\n\ - First line: imperative mood summary under 72 characters.\n\ - Add a blank line then a detailed body that explains what changed and why.\n\ - List the key changes as bullet points if there are multiple distinct changes." - } - CommitStyle::Brief => { - "Write a concise commit message in imperative mood.\n\ - Keep it to a single line under 72 characters." - } - }; - - let max_diff_len = 200_000; - let diff_text = if diff.len() > max_diff_len { - let truncated = crate::tools::safe_truncate(diff, max_diff_len); - let truncation_point = truncated.rfind('\n').unwrap_or(truncated.len()); - format!( - "{}\n\n[diff truncated -- showing {}/{} bytes]", - &diff[..truncation_point], - truncation_point, - diff.len() - ) - } else { - diff.to_string() - }; - - let context_section = match project_context { - Some(context) => format!("Project Context:\n{context}\n\n"), - None => String::new(), - }; - - format!( - "You are a Git commit message generator. Generate ONLY the commit message, nothing else.\n\ - No markdown formatting, no code blocks, no explanations.\n\n\ - {style_instruction}\n\n\ - You have access to tools to get more context about the repository. Use them if you need to:\n\ - - Understand what a changed file does (get_file_content)\n\ - - See the commit message style used in this project (get_recent_commits)\n\ - - Understand how a file has evolved (get_file_history)\n\n\ - Only use tools if the diff is unclear and you need more context. If the changes are self-explanatory, generate the commit message directly.\n\n\ - {context_section}\ - Files changed:\n{summary}\n\n\ - Diff:\n{diff_text}" +fn iterations_exhausted() -> anyhow::Error { + anyhow::anyhow!( + "The model kept asking for more context and never wrote a message. Try turning off \ + \"Let the model read files\" in Settings > AI." ) } -/// Gemini response with potential function calls. +/// Execute a tool, reporting it to the UI first so the chip shows what is +/// happening rather than an opaque spinner. +fn run_tool( + call: &ToolCall, + repo_path: &Path, + budget: &mut ToolBudget, + status: &StatusSender, +) -> ToolResult { + let _ = status.unbounded_send(AiGenerator::describe_tool_call(call)); + execute_tool_within(call, repo_path, budget) +} + +/// A tool's output as the model sees it. Failures are handed back as text so +/// the model can adapt rather than the whole generation collapsing. +fn tool_output(result: &ToolResult) -> String { + match &result.result { + Ok(output) => output.clone(), + Err(error) => format!("Error: {error}"), + } +} + +// ============================================================================ +// Gemini response types +// ============================================================================ + +/// Every nested field is optional. +/// +/// The non-tools variant used to require `candidates`, `content`, `parts` and +/// `text`, so a blocked prompt, a `SAFETY` finish, a thought-only part or a +/// `MAX_TOKENS` stop all produced the same opaque parse error. +#[derive(Debug, Default, Deserialize)] +struct GeminiResponse { + #[serde(default)] + candidates: Vec, + #[serde(default, rename = "promptFeedback")] + prompt_feedback: Option, +} + #[derive(Debug, Deserialize)] -struct GeminiToolResponse { - candidates: Vec, +struct GeminiPromptFeedback { + #[serde(default, rename = "blockReason")] + block_reason: Option, } #[derive(Debug, Deserialize)] -struct GeminiToolCandidate { - content: GeminiToolContent, +struct GeminiCandidate { + #[serde(default)] + content: Option, + #[serde(default, rename = "finishReason")] + finish_reason: Option, } #[derive(Debug, Deserialize)] -struct GeminiToolContent { - parts: Vec, +struct GeminiContent { + #[serde(default)] + parts: Vec, } #[derive(Debug, Deserialize)] -struct GeminiToolPart { +struct GeminiPart { #[serde(default)] - text: String, - #[serde(rename = "functionCall")] + text: Option, + #[serde(default, rename = "functionCall")] function_call: Option, /// Opaque signature of the model's reasoning about this function call. - /// Must be echoed back verbatim in the next request so the model retains - /// reasoning continuity across tool-call round trips. + /// Echoed back verbatim so reasoning continuity survives the round trip. #[serde(default, rename = "thoughtSignature")] thought_signature: Option, } @@ -1099,5 +895,250 @@ struct GeminiToolPart { struct GeminiFunctionCall { name: String, #[serde(default)] - args: serde_json::Value, + args: Value, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_gemini(json: Value) -> GeminiResponse { + serde_json::from_value(json).expect("Gemini response must not fail to parse") + } + + // ── M3/X4: the four shapes that all produced one opaque error ── + + #[test] + fn a_safety_blocked_prompt_parses_and_names_the_reason() { + let json = parse_gemini(serde_json::json!({ + "promptFeedback": { "blockReason": "SAFETY" } + })); + assert!(json.candidates.is_empty()); + assert_eq!( + json.prompt_feedback.unwrap().block_reason.as_deref(), + Some("SAFETY") + ); + } + + #[test] + fn a_candidate_with_no_content_parses() { + let json = parse_gemini(serde_json::json!({ + "candidates": [{ "finishReason": "SAFETY" }] + })); + assert!(json.candidates[0].content.is_none()); + assert_eq!(json.candidates[0].finish_reason.as_deref(), Some("SAFETY")); + } + + #[test] + fn a_thought_part_carrying_no_text_parses() { + let json = parse_gemini(serde_json::json!({ + "candidates": [{ + "content": { "parts": [{ "thoughtSignature": "abc" }] } + }] + })); + let part = &json.candidates[0].content.as_ref().unwrap().parts[0]; + assert_eq!(part.text, None); + assert_eq!(part.thought_signature.as_deref(), Some("abc")); + } + + #[test] + fn a_max_tokens_stop_with_empty_parts_parses() { + let json = parse_gemini(serde_json::json!({ + "candidates": [{ "content": { "parts": [] }, "finishReason": "MAX_TOKENS" }] + })); + assert!(json.candidates[0] + .content + .as_ref() + .unwrap() + .parts + .is_empty()); + } + + // ── X2: every part, not just the first ──────────────────────── + + #[test] + fn a_leading_thought_part_does_not_hide_the_function_call_behind_it() { + let json = parse_gemini(serde_json::json!({ + "candidates": [{ "content": { "parts": [ + { "text": "" }, + { "functionCall": { "name": "get_diff", "args": { "kind": "staged" } } } + ]}}] + })); + let parts = &json.candidates[0].content.as_ref().unwrap().parts; + assert_eq!(parts.len(), 2); + let calls: Vec<&str> = parts + .iter() + .filter_map(|p| p.function_call.as_ref()) + .map(|c| c.name.as_str()) + .collect(); + assert_eq!(calls, vec!["get_diff"]); + } + + #[test] + fn parallel_function_calls_in_one_turn_are_all_visible() { + let json = parse_gemini(serde_json::json!({ + "candidates": [{ "content": { "parts": [ + { "functionCall": { "name": "get_diff", "args": {} } }, + { "functionCall": { "name": "get_branch_list", "args": {} } } + ]}}] + })); + let calls: Vec<&str> = json.candidates[0] + .content + .as_ref() + .unwrap() + .parts + .iter() + .filter_map(|p| p.function_call.as_ref()) + .map(|c| c.name.as_str()) + .collect(); + assert_eq!(calls, vec!["get_diff", "get_branch_list"]); + } + + #[test] + fn multi_part_text_is_available_in_order() { + let json = parse_gemini(serde_json::json!({ + "candidates": [{ "content": { "parts": [ + { "text": "feat: " }, + { "text": "do the thing" } + ]}}] + })); + let text: String = json.candidates[0] + .content + .as_ref() + .unwrap() + .parts + .iter() + .filter_map(|p| p.text.as_deref()) + .collect(); + assert_eq!(text, "feat: do the thing"); + } + + // ── error extraction ────────────────────────────────────────── + + #[test] + fn a_top_level_error_object_is_recognised_in_every_common_shape() { + assert_eq!( + error_message_in(&serde_json::json!({ "error": { "message": "bad key" } })).as_deref(), + Some("bad key") + ); + assert_eq!( + error_message_in(&serde_json::json!({ "error": "plain string" })).as_deref(), + Some("plain string") + ); + assert_eq!( + error_message_in(&serde_json::json!({ "message": "top level" })).as_deref(), + Some("top level") + ); + } + + #[test] + fn an_ordinary_completion_is_not_mistaken_for_an_error() { + let json = serde_json::json!({ + "choices": [{ "message": { "content": "feat: x" }, "finish_reason": "stop" }] + }); + assert_eq!(error_message_in(&json), None); + } + + #[test] + fn an_empty_error_message_is_treated_as_absent() { + assert_eq!( + error_message_in(&serde_json::json!({ "error": { "message": " " } })), + None + ); + } + + // ── Anthropic content blocks ────────────────────────────────── + + #[test] + fn the_first_non_empty_text_block_wins_over_a_leading_thinking_block() { + let content = vec![ + serde_json::json!({ "type": "thinking", "thinking": "hmm" }), + serde_json::json!({ "type": "text", "text": " " }), + serde_json::json!({ "type": "text", "text": " feat: x " }), + ]; + assert_eq!(first_text_block(&content).as_deref(), Some("feat: x")); + } + + #[test] + fn a_tool_use_only_response_yields_no_text() { + let content = vec![serde_json::json!({ "type": "tool_use", "name": "get_diff" })]; + assert_eq!(first_text_block(&content), None); + } + + // ── tool descriptions ───────────────────────────────────────── + + fn call(name: &str, arguments: Value) -> ToolCall { + ToolCall { + id: "1".into(), + name: name.into(), + arguments, + } + } + + #[test] + fn every_tool_gets_a_legible_progress_description() { + let cases = [ + ( + call("get_file_content", serde_json::json!({ "path": "diff.rs" })), + "Reading diff.rs", + ), + ( + call("get_file_history", serde_json::json!({ "path": "lib.rs" })), + "File history: lib.rs", + ), + ( + call("get_recent_commits", serde_json::json!({ "count": 20 })), + "Reading 20 recent commits", + ), + ( + call("get_diff", serde_json::json!({ "kind": "unstaged" })), + "Reading unstaged diff", + ), + ( + call("get_branch_list", serde_json::json!({})), + "Listing branches", + ), + ( + call("get_file_tree", serde_json::json!({ "path": "src" })), + "Scanning src", + ), + ]; + for (tool_call, expected) in cases { + assert_eq!(AiGenerator::describe_tool_call(&tool_call), expected); + } + } + + #[test] + fn malformed_tool_arguments_still_produce_a_description() { + assert_eq!( + AiGenerator::describe_tool_call(&call("get_file_content", serde_json::json!({}))), + "Reading ?" + ); + assert_eq!( + AiGenerator::describe_tool_call(&call("get_diff", Value::Null)), + "Reading staged diff" + ); + assert_eq!( + AiGenerator::describe_tool_call(&call( + "get_recent_commits", + serde_json::json!({ "count": "many" }) + )), + "Reading 5 recent commits" + ); + assert_eq!( + AiGenerator::describe_tool_call(&call("future_tool", serde_json::json!({}))), + "Calling future_tool" + ); + } + + // ── tool output plumbing ────────────────────────────────────── + + #[test] + fn a_failed_tool_is_reported_to_the_model_rather_than_ending_the_generation() { + let result = ToolResult { + call_id: "1".into(), + result: Err("Refused: .env looks like a credentials file".into()), + }; + assert!(tool_output(&result).starts_with("Error: Refused")); + } } diff --git a/crates/rgitui_ai/src/prompt.rs b/crates/rgitui_ai/src/prompt.rs new file mode 100644 index 00000000..95d315fe --- /dev/null +++ b/crates/rgitui_ai/src/prompt.rs @@ -0,0 +1,355 @@ +//! Prompt construction. +//! +//! The plain and tool-calling prompts used to duplicate the whole style match +//! and the whole truncation block verbatim; they now differ only by the one +//! paragraph that mentions tools. + +use std::path::Path; + +use crate::tools::safe_truncate; + +/// Commit message style options. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CommitStyle { + /// Conventional Commits format: `feat(scope): description`. + /// + /// The default, matching `default_commit_style()` in settings. The two + /// used to disagree, so an unrecognised string quietly produced a + /// different style than a fresh install. + #[default] + Conventional, + /// Plain English descriptive format. + Descriptive, + /// One-line brief format. + Brief, +} + +impl CommitStyle { + pub const ALL: &'static [CommitStyle] = &[ + CommitStyle::Conventional, + CommitStyle::Descriptive, + CommitStyle::Brief, + ]; + + /// The persisted id. + pub fn id(self) -> &'static str { + match self { + CommitStyle::Conventional => "conventional", + CommitStyle::Descriptive => "descriptive", + CommitStyle::Brief => "brief", + } + } + + pub fn display_name(self) -> &'static str { + match self { + CommitStyle::Conventional => "Conventional", + CommitStyle::Descriptive => "Descriptive", + CommitStyle::Brief => "Brief", + } + } + + /// A representative first line, so the three labels stop being guesses + /// until the user has seen output. + pub fn example(self) -> &'static str { + match self { + CommitStyle::Conventional => "feat(diff): add word-level intra-line highlighting", + CommitStyle::Descriptive => "Add word-level highlighting inside changed diff lines", + CommitStyle::Brief => "Highlight intra-line diff changes", + } + } + + /// Parse a persisted id. Unlike the old `FromStr` — whose `Infallible` + /// error type made every caller's fallback branch unreachable — an + /// unrecognised value is reported rather than silently becoming a style + /// the user never chose. + pub fn from_id(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + Self::ALL + .iter() + .copied() + .find(|style| style.id() == normalized) + } + + fn instruction(self) -> &'static str { + match self { + CommitStyle::Conventional => { + "Use the Conventional Commits format: (): \n\ + Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore\n\ + Keep the first line under 72 characters.\n\ + Add a blank line then a detailed body that explains what changed and why.\n\ + List the key changes as bullet points if there are multiple distinct changes." + } + CommitStyle::Descriptive => { + "Write a clear, descriptive commit message.\n\ + First line: imperative mood summary under 72 characters.\n\ + Add a blank line then a detailed body that explains what changed and why.\n\ + List the key changes as bullet points if there are multiple distinct changes." + } + CommitStyle::Brief => { + "Write a concise commit message in imperative mood.\n\ + Keep it to a single line under 72 characters." + } + } + } +} + +impl std::str::FromStr for CommitStyle { + type Err = (); + + fn from_str(s: &str) -> Result { + Self::from_id(s).ok_or(()) + } +} + +/// Cap on the diff included in the prompt. +/// +/// Lowered from 200 KB (~50-60k tokens). In the tool loop the whole prompt is +/// re-sent every iteration, so the old cap could cost ~180k input tokens for +/// one commit message across three round trips. +pub(crate) const MAX_DIFF_BYTES: usize = 40_000; + +/// Truncate a diff at a line boundary, always leaving a marker so the model +/// knows it is reasoning about a partial change set. +pub(crate) fn truncate_diff(diff: &str, max_bytes: usize) -> String { + if diff.len() <= max_bytes { + return diff.to_string(); + } + let truncated = safe_truncate(diff, max_bytes); + let cut = truncated.rfind('\n').unwrap_or(truncated.len()); + format!( + "{}\n\n[diff truncated -- showing {}/{} bytes]", + &truncated[..cut], + cut, + diff.len() + ) +} + +const TOOL_PARAGRAPH: &str = "You have access to tools to get more context about the repository. Use them if you need to:\n\ + - Understand what a changed file does (get_file_content)\n\ + - See the commit message style used in this project (get_recent_commits)\n\ + - Understand how a file has evolved (get_file_history)\n\n\ + Only use tools if the diff is unclear and you need more context. If the changes are self-explanatory, generate the commit message directly.\n\n"; + +/// Build the prompt. `with_tools` adds the paragraph describing the tools and +/// nothing else — everything else is shared, by construction. +pub(crate) fn build_prompt( + diff: &str, + summary: &str, + commit_style: CommitStyle, + project_context: Option<&str>, + with_tools: bool, +) -> String { + let style_instruction = commit_style.instruction(); + let diff_text = truncate_diff(diff, MAX_DIFF_BYTES); + let context_section = match project_context { + Some(context) => format!("Project Context:\n{context}\n\n"), + None => String::new(), + }; + let tool_section = if with_tools { TOOL_PARAGRAPH } else { "" }; + + format!( + "You are a Git commit message generator. Generate ONLY the commit message, nothing else.\n\ + No markdown formatting, no code blocks, no explanations.\n\n\ + {style_instruction}\n\n\ + {tool_section}\ + {context_section}\ + Files changed:\n{summary}\n\n\ + Diff:\n{diff_text}" + ) +} + +pub(crate) const PROJECT_CONTEXT_FILES: &[&str] = &["README.md", "CLAUDE.md", "AGENTS.md"]; +pub(crate) const MAX_PROJECT_CONTEXT_BYTES: usize = 50_000; + +/// Read the project-context files, if any exist. Blocking I/O — call it from a +/// background task. +pub(crate) fn collect_project_context(repo_path: &Path) -> Option { + let mut combined = String::new(); + + for filename in PROJECT_CONTEXT_FILES { + let file_path = repo_path.join(filename); + if let Ok(contents) = std::fs::read_to_string(&file_path) { + if !contents.trim().is_empty() { + combined.push_str(&format!("=== {filename} ===\n{contents}\n\n")); + } + } + } + + if combined.is_empty() { + return None; + } + + if combined.len() > MAX_PROJECT_CONTEXT_BYTES { + let cut = { + let safe = safe_truncate(&combined, MAX_PROJECT_CONTEXT_BYTES); + safe.rfind('\n').unwrap_or(safe.len()) + }; + combined.truncate(cut); + combined.push_str("\n\n[project context truncated]"); + } + + Some(combined) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + // ── CommitStyle ─────────────────────────────────────────────── + + #[test] + fn commit_style_round_trips_through_its_id() { + for style in CommitStyle::ALL { + assert_eq!(CommitStyle::from_id(style.id()), Some(*style)); + } + } + + /// The settings default was `"conventional"` while the enum default was + /// `Descriptive`, so a typo silently produced a style the user never + /// chose. The two now agree, and an unknown value is reported. + #[test] + fn the_enum_default_matches_the_settings_default() { + assert_eq!(CommitStyle::default().id(), "conventional"); + } + + #[test] + fn an_unknown_style_is_reported_rather_than_silently_substituted() { + assert_eq!( + CommitStyle::from_id("Conventional"), + Some(CommitStyle::Conventional) + ); + assert_eq!(CommitStyle::from_id("verbose"), None); + assert_eq!("verbose".parse::(), Err(())); + } + + #[test] + fn every_style_has_a_distinct_instruction_and_example() { + let mut instructions: Vec<&str> = + CommitStyle::ALL.iter().map(|s| s.instruction()).collect(); + instructions.sort_unstable(); + instructions.dedup(); + assert_eq!(instructions.len(), CommitStyle::ALL.len()); + + for style in CommitStyle::ALL { + assert!(!style.example().is_empty()); + assert!(!style.display_name().is_empty()); + } + } + + // ── truncation ──────────────────────────────────────────────── + + #[test] + fn a_diff_at_exactly_the_cap_is_not_truncated() { + let diff = "a".repeat(MAX_DIFF_BYTES); + let out = truncate_diff(&diff, MAX_DIFF_BYTES); + assert_eq!(out, diff); + assert!(!out.contains("truncated")); + } + + #[test] + fn one_byte_over_the_cap_truncates_and_says_so() { + let diff = format!("{}\nx", "a".repeat(MAX_DIFF_BYTES)); + let out = truncate_diff(&diff, MAX_DIFF_BYTES); + assert!(out.contains("[diff truncated")); + assert!(out.contains(&format!("/{} bytes]", diff.len()))); + } + + #[test] + fn truncation_never_splits_a_multi_byte_character() { + // A 3-byte character straddling the cut point used to be the panic + // case for naive slicing. + let mut diff = "a".repeat(MAX_DIFF_BYTES - 1); + diff.push('☃'); + diff.push_str("tail"); + let out = truncate_diff(&diff, MAX_DIFF_BYTES); + assert!(out.is_char_boundary(out.len())); + } + + #[test] + fn an_empty_diff_produces_an_empty_body_not_a_marker() { + assert_eq!(truncate_diff("", MAX_DIFF_BYTES), ""); + } + + // ── prompts ─────────────────────────────────────────────────── + + #[test] + fn the_tool_prompt_differs_from_the_plain_one_only_by_the_tool_paragraph() { + let plain = build_prompt("D", "S", CommitStyle::Conventional, None, false); + let with_tools = build_prompt("D", "S", CommitStyle::Conventional, None, true); + assert_eq!(with_tools.replace(TOOL_PARAGRAPH, ""), plain); + } + + #[test] + fn the_style_instruction_reaches_the_prompt() { + for style in CommitStyle::ALL { + let prompt = build_prompt("D", "S", *style, None, false); + assert!(prompt.contains(style.instruction())); + } + } + + #[test] + fn project_context_is_included_only_when_present() { + let without = build_prompt("D", "S", CommitStyle::Brief, None, false); + assert!(!without.contains("Project Context:")); + let with = build_prompt("D", "S", CommitStyle::Brief, Some("CTX"), false); + assert!(with.contains("Project Context:\nCTX")); + } + + #[test] + fn an_oversize_diff_is_truncated_inside_the_prompt() { + let diff = format!("{}\ntail", "a".repeat(MAX_DIFF_BYTES + 10)); + let prompt = build_prompt(&diff, "S", CommitStyle::Brief, None, false); + assert!(prompt.contains("[diff truncated")); + assert!(prompt.len() < diff.len() + 2_000); + } + + // ── project context collection ──────────────────────────────── + + #[test] + fn no_context_files_yields_none() { + let dir = TempDir::new().unwrap(); + assert!(collect_project_context(dir.path()).is_none()); + } + + #[test] + fn one_context_file_is_wrapped_with_its_filename() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("README.md"), "hello").unwrap(); + let context = collect_project_context(dir.path()).unwrap(); + assert!(context.contains("=== README.md ===")); + assert!(context.contains("hello")); + } + + #[test] + fn an_empty_context_file_is_skipped() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("README.md"), " \n ").unwrap(); + assert!(collect_project_context(dir.path()).is_none()); + } + + #[test] + fn every_context_file_is_collected_in_order() { + let dir = TempDir::new().unwrap(); + for name in PROJECT_CONTEXT_FILES { + std::fs::write(dir.path().join(name), format!("body of {name}")).unwrap(); + } + let context = collect_project_context(dir.path()).unwrap(); + let readme = context.find("README.md").unwrap(); + let claude = context.find("CLAUDE.md").unwrap(); + assert!(readme < claude); + } + + #[test] + fn an_oversize_context_is_truncated_with_a_marker() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("README.md"), + "x".repeat(MAX_PROJECT_CONTEXT_BYTES + 1_000), + ) + .unwrap(); + let context = collect_project_context(dir.path()).unwrap(); + assert!(context.ends_with("[project context truncated]")); + assert!(context.len() < MAX_PROJECT_CONTEXT_BYTES + 100); + } +} diff --git a/crates/rgitui_ai/src/provider.rs b/crates/rgitui_ai/src/provider.rs new file mode 100644 index 00000000..9fb563e2 --- /dev/null +++ b/crates/rgitui_ai/src/provider.rs @@ -0,0 +1,524 @@ +//! Endpoint resolution and request-body construction, per provider. +//! +//! Everything here is a pure function over `(provider, model, …)` so the shape +//! of every outgoing request is unit-testable without a network, a display, or +//! an API key. The Anthropic tool loop shipping an empty `messages` array — +//! which meant that provider had never once worked — is precisely the class of +//! bug these functions exist to make visible. + +use rgitui_settings::AiProvider; +use serde_json::Value; + +/// Attribution headers for OpenRouter's public model leaderboard. Optional and +/// never functional; the user can turn them off in Settings. +pub(crate) const OPENROUTER_ATTRIBUTION: &[(&str, &str)] = &[ + ("HTTP-Referer", "https://github.com/noahbclarkson/rgitui"), + ("X-Title", "rgitui"), +]; + +/// How a provider expects the API key to be presented. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AuthStyle { + /// `Authorization: Bearer ` + Bearer, + /// `x-api-key: ` plus `anthropic-version` + AnthropicHeader, + /// `x-goog-api-key: `. Deliberately a header and not the `?key=` + /// query parameter the code used to build: query strings land in proxy + /// logs, TLS-inspecting middleboxes and any error path that prints a URL. + GoogleHeader, +} + +/// How this provider expects its API key to be presented. +pub(crate) fn auth_style(provider: AiProvider) -> AuthStyle { + match provider { + AiProvider::Gemini => AuthStyle::GoogleHeader, + AiProvider::Anthropic => AuthStyle::AnthropicHeader, + AiProvider::OpenAi | AiProvider::DeepSeek | AiProvider::OpenRouter => AuthStyle::Bearer, + } +} + +/// A resolved OpenAI-compatible chat endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OpenAiCompatEndpoint { + /// Human-readable name used in user-facing error messages. + pub provider: AiProvider, + pub url: String, + /// Appended after `Authorization` and `Content-Type`. + pub extra_headers: Vec<(&'static str, &'static str)>, +} + +/// The built-in chat-completions URL for an OpenAI-compatible provider. +fn builtin_openai_compat_url(provider: AiProvider) -> &'static str { + match provider { + AiProvider::OpenAi => "https://api.openai.com/v1/chat/completions", + AiProvider::DeepSeek => "https://api.deepseek.com/chat/completions", + AiProvider::OpenRouter => "https://openrouter.ai/api/v1/chat/completions", + // Gemini and Anthropic have their own request shapes and never route + // through here; the OpenAI URL is the only sane placeholder and the + // caller is guarded by `is_openai_compatible`. + AiProvider::Gemini | AiProvider::Anthropic => "https://api.openai.com/v1/chat/completions", + } +} + +/// Resolve the endpoint for an OpenAI-compatible provider, honouring a +/// validated `base_url_override` when one is set. +/// +/// The override applies only to this family: Gemini and Anthropic keep their +/// fixed endpoints, so pointing the field at a gateway can never silently +/// redirect a request the gateway does not understand. +pub(crate) fn openai_compat_endpoint( + provider: AiProvider, + base_url_override: &str, + openrouter_attribution: bool, +) -> OpenAiCompatEndpoint { + let url = match normalize_base_url(base_url_override) { + Some(base) if provider.is_openai_compatible() => format!("{base}/chat/completions"), + _ => builtin_openai_compat_url(provider).to_string(), + }; + + let extra_headers = if provider == AiProvider::OpenRouter && openrouter_attribution { + OPENROUTER_ATTRIBUTION.to_vec() + } else { + Vec::new() + }; + + OpenAiCompatEndpoint { + provider, + url, + extra_headers, + } +} + +/// Trim a user-supplied base URL to the form the endpoint builder expects: +/// no trailing slash, and no trailing `/chat/completions` the user may have +/// pasted from a curl example. Returns `None` for an empty field, which means +/// "use the built-in URL". +fn normalize_base_url(base_url_override: &str) -> Option { + let trimmed = base_url_override.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + let trimmed = trimmed + .strip_suffix("/chat/completions") + .unwrap_or(trimmed) + .trim_end_matches('/'); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Why a `base_url_override` was rejected, phrased as something the user can +/// act on. Settings shows this inline; nothing is persisted until it passes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BaseUrlError { + NotAUrl, + InsecureScheme, + HasQueryOrFragment, +} + +impl BaseUrlError { + pub fn message(&self) -> &'static str { + match self { + BaseUrlError::NotAUrl => { + "Enter a full URL, for example https://my-gateway.example.com/v1" + } + BaseUrlError::InsecureScheme => { + "Use https://. Plain http:// is only allowed for localhost and 127.0.0.1." + } + BaseUrlError::HasQueryOrFragment => { + "Remove the query string or #fragment — only the base path is used." + } + } + } +} + +/// Validate a user-supplied base URL. +/// +/// An empty field is valid and means "use the built-in URL"; the default is +/// deliberately never stored in the field, so it cannot freeze at whatever +/// shipped. +pub fn validate_base_url(value: &str) -> Result<(), BaseUrlError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(()); + } + if trimmed.contains('?') || trimmed.contains('#') { + return Err(BaseUrlError::HasQueryOrFragment); + } + + let (scheme, rest) = match trimmed.split_once("://") { + Some(parts) => parts, + None => return Err(BaseUrlError::NotAUrl), + }; + let host = rest.split('/').next().unwrap_or(""); + if host.is_empty() || host.starts_with(':') { + return Err(BaseUrlError::NotAUrl); + } + + match scheme.to_ascii_lowercase().as_str() { + "https" => Ok(()), + // Ollama's `/v1` on the loopback interface is the main local case, and + // it does not serve TLS. + "http" if is_loopback_host(host) => Ok(()), + "http" => Err(BaseUrlError::InsecureScheme), + _ => Err(BaseUrlError::NotAUrl), + } +} + +fn is_loopback_host(host: &str) -> bool { + let bare = host.split(':').next().unwrap_or(host); + matches!(bare, "localhost" | "127.0.0.1" | "[::1]" | "::1") +} + +/// The host a request will actually reach, for the settings warning that says +/// plainly where the user's API key is being sent. +pub fn effective_host(provider: AiProvider, base_url_override: &str) -> String { + match normalize_base_url(base_url_override) { + Some(base) if provider.is_openai_compatible() => base + .split_once("://") + .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_string()) + .unwrap_or(base), + _ => provider.default_host().to_string(), + } +} + +/// The Gemini `generateContent` URL. The key travels in a header, never here. +pub(crate) fn gemini_endpoint(model: &str) -> String { + format!("https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent") +} + +pub(crate) const ANTHROPIC_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +pub(crate) const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// The maximum tokens a commit message may consume. Generous enough for a +/// reasoning model's hidden tokens plus a real message body. +pub(crate) const MAX_OUTPUT_TOKENS: u32 = 4096; + +/// The opening user turn of every conversation. +/// +/// Anthropic rejects an empty `messages` array with a 400, which is why that +/// provider had never once produced a commit message. +pub(crate) const OPENING_USER_TURN: &str = "Generate a commit message for these changes."; + +/// Build the first-iteration request body for a provider. +/// +/// `messages` is the conversation so far — empty on the first iteration, which +/// is the case every provider must handle by seeding an opening turn. +pub(crate) fn build_request_body( + provider: AiProvider, + model: &str, + prompt: &str, + tools: Option<&Value>, + messages: &[Value], +) -> Value { + match provider { + AiProvider::Gemini => { + let mut contents: Vec = vec![serde_json::json!({ + "role": "user", + "parts": [{ "text": prompt }] + })]; + contents.extend(messages.iter().cloned()); + let mut body = serde_json::json!({ + "contents": contents, + "generationConfig": { + "temperature": 0.3, + "maxOutputTokens": MAX_OUTPUT_TOKENS, + "topP": 0.8 + } + }); + if let Some(tools) = tools { + body["tools"] = serde_json::json!([tools]); + } + body + } + AiProvider::Anthropic => { + let mut turns: Vec = vec![serde_json::json!({ + "role": "user", + "content": OPENING_USER_TURN + })]; + turns.extend(messages.iter().cloned()); + let mut body = serde_json::json!({ + "model": model, + "max_tokens": MAX_OUTPUT_TOKENS, + // A cache breakpoint after the system prompt means iterations + // two and three read the (large) diff from cache instead of + // re-billing it as fresh input on every round trip. + "system": [{ + "type": "text", + "text": prompt, + "cache_control": { "type": "ephemeral" } + }], + "messages": turns, + }); + if let Some(tools) = tools { + body["tools"] = tools.clone(); + } + body + } + _ => { + let mut turns: Vec = vec![ + serde_json::json!({ "role": "system", "content": prompt }), + serde_json::json!({ "role": "user", "content": OPENING_USER_TURN }), + ]; + turns.extend(messages.iter().cloned()); + let mut body = serde_json::json!({ + "model": model, + "messages": turns, + "temperature": 0.3, + "max_tokens": MAX_OUTPUT_TOKENS, + }); + if let Some(tools) = tools { + body["tools"] = tools.clone(); + body["tool_choice"] = serde_json::json!("auto"); + } + body + } + } +} + +/// The conversation turns carried by a request body, for tests and for the +/// non-empty-first-turn invariant. +#[cfg(test)] +pub(crate) fn body_turns(provider: AiProvider, body: &Value) -> Vec { + let key = match provider { + AiProvider::Gemini => "contents", + _ => "messages", + }; + body[key].as_array().cloned().unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all_tools() -> Value { + serde_json::json!([{ "name": "get_diff" }]) + } + + // ── the C1 regression test ──────────────────────────────────── + + /// Anthropic's Messages API rejects `messages: []` with a 400. The tool + /// loop shipped exactly that on its first iteration, so the provider had + /// never worked for anyone. Assert it for every provider, not just the one + /// that was broken. + #[test] + fn every_provider_sends_a_non_empty_first_turn() { + for provider in AiProvider::ALL { + let body = build_request_body( + *provider, + provider.default_model(), + "PROMPT", + Some(&all_tools()), + &[], + ); + let turns = body_turns(*provider, &body); + assert!( + !turns.is_empty(), + "{} sends an empty conversation on iteration 1", + provider.id() + ); + } + } + + #[test] + fn anthropic_carries_the_prompt_in_system_not_in_the_user_turn() { + let body = build_request_body(AiProvider::Anthropic, "m", "PROMPT", None, &[]); + assert_eq!(body["system"][0]["text"], "PROMPT"); + assert_eq!(body["messages"][0]["role"], "user"); + assert_eq!(body["messages"][0]["content"], OPENING_USER_TURN); + } + + #[test] + fn anthropic_sets_a_cache_breakpoint_on_the_prompt() { + let body = build_request_body(AiProvider::Anthropic, "m", "PROMPT", None, &[]); + assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral"); + } + + #[test] + fn openai_compatible_seeds_a_system_and_a_user_turn() { + for provider in [ + AiProvider::OpenAi, + AiProvider::DeepSeek, + AiProvider::OpenRouter, + ] { + let body = build_request_body(provider, "m", "PROMPT", None, &[]); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][0]["content"], "PROMPT"); + assert_eq!(body["messages"][1]["role"], "user"); + } + } + + #[test] + fn gemini_seeds_contents_with_the_prompt() { + let body = build_request_body(AiProvider::Gemini, "m", "PROMPT", None, &[]); + assert_eq!(body["contents"][0]["parts"][0]["text"], "PROMPT"); + assert_eq!(body["contents"][0]["role"], "user"); + } + + #[test] + fn history_is_appended_after_the_seeded_turns() { + let history = vec![serde_json::json!({ "role": "assistant", "content": "hi" })]; + let body = build_request_body(AiProvider::OpenAi, "m", "PROMPT", None, &history); + let turns = body_turns(AiProvider::OpenAi, &body); + assert_eq!(turns.len(), 3); + assert_eq!(turns[2]["content"], "hi"); + } + + #[test] + fn tools_are_omitted_entirely_when_not_requested() { + for provider in AiProvider::ALL { + let body = build_request_body(*provider, "m", "PROMPT", None, &[]); + assert!( + body.get("tools").is_none(), + "{} sent a tools field with tools disabled", + provider.id() + ); + } + } + + #[test] + fn openai_compatible_sets_tool_choice_only_alongside_tools() { + let with = build_request_body(AiProvider::OpenAi, "m", "P", Some(&all_tools()), &[]); + assert_eq!(with["tool_choice"], "auto"); + let without = build_request_body(AiProvider::OpenAi, "m", "P", None, &[]); + assert!(without.get("tool_choice").is_none()); + } + + /// The o-series rejects `max_tokens` and a non-default `temperature`, so + /// those models are no longer offered at all. Every model still offered + /// must accept the one body shape this function emits. + #[test] + fn openai_compatible_uses_max_tokens_not_max_completion_tokens() { + let body = build_request_body(AiProvider::OpenAi, "gpt-5.6-luna", "P", None, &[]); + assert_eq!(body["max_tokens"], MAX_OUTPUT_TOKENS); + assert!(body.get("max_completion_tokens").is_none()); + } + + // ── endpoints ───────────────────────────────────────────────── + + #[test] + fn each_openai_compatible_provider_gets_its_own_url() { + assert_eq!( + openai_compat_endpoint(AiProvider::OpenAi, "", true).url, + "https://api.openai.com/v1/chat/completions" + ); + assert_eq!( + openai_compat_endpoint(AiProvider::DeepSeek, "", true).url, + "https://api.deepseek.com/chat/completions" + ); + assert_eq!( + openai_compat_endpoint(AiProvider::OpenRouter, "", true).url, + "https://openrouter.ai/api/v1/chat/completions" + ); + } + + #[test] + fn attribution_headers_are_openrouter_only_and_opt_out_able() { + let on = openai_compat_endpoint(AiProvider::OpenRouter, "", true); + assert_eq!(on.extra_headers.len(), 2); + assert!(on.extra_headers.iter().any(|(k, _)| *k == "HTTP-Referer")); + assert!(on.extra_headers.iter().any(|(k, _)| *k == "X-Title")); + + let off = openai_compat_endpoint(AiProvider::OpenRouter, "", false); + assert!(off.extra_headers.is_empty()); + + let other = openai_compat_endpoint(AiProvider::OpenAi, "", true); + assert!(other.extra_headers.is_empty()); + } + + #[test] + fn base_url_override_applies_only_to_the_openai_compatible_family() { + let overridden = + openai_compat_endpoint(AiProvider::OpenAi, "https://gw.example.com/v1", true); + assert_eq!(overridden.url, "https://gw.example.com/v1/chat/completions"); + + // Gemini and Anthropic never route through here, and asking for the + // compat endpoint for them must not adopt the override. + let gemini = openai_compat_endpoint(AiProvider::Gemini, "https://gw.example.com/v1", true); + assert_eq!(gemini.url, "https://api.openai.com/v1/chat/completions"); + } + + #[test] + fn base_url_override_tolerates_a_pasted_full_endpoint_and_trailing_slash() { + for input in [ + "https://gw.example.com/v1", + "https://gw.example.com/v1/", + "https://gw.example.com/v1/chat/completions", + ] { + assert_eq!( + openai_compat_endpoint(AiProvider::OpenAi, input, true).url, + "https://gw.example.com/v1/chat/completions", + "input {input}" + ); + } + } + + #[test] + fn gemini_url_carries_no_key_query_parameter() { + let url = gemini_endpoint("gemini-3.1-flash-lite"); + assert!(!url.contains("key=")); + assert!(url.ends_with(":generateContent")); + } + + // ── base url validation ─────────────────────────────────────── + + #[test] + fn an_empty_override_is_valid_and_means_use_the_built_in_url() { + assert_eq!(validate_base_url(""), Ok(())); + assert_eq!(validate_base_url(" "), Ok(())); + } + + #[test] + fn https_is_required_except_on_loopback() { + assert_eq!(validate_base_url("https://gw.example.com/v1"), Ok(())); + assert_eq!(validate_base_url("http://localhost:11434/v1"), Ok(())); + assert_eq!(validate_base_url("http://127.0.0.1:11434/v1"), Ok(())); + assert_eq!( + validate_base_url("http://gw.example.com/v1"), + Err(BaseUrlError::InsecureScheme) + ); + } + + #[test] + fn a_query_string_or_fragment_is_rejected() { + assert_eq!( + validate_base_url("https://gw.example.com/v1?key=abc"), + Err(BaseUrlError::HasQueryOrFragment) + ); + assert_eq!( + validate_base_url("https://gw.example.com/v1#x"), + Err(BaseUrlError::HasQueryOrFragment) + ); + } + + #[test] + fn a_bare_host_or_unknown_scheme_is_rejected() { + assert_eq!( + validate_base_url("gw.example.com"), + Err(BaseUrlError::NotAUrl) + ); + assert_eq!( + validate_base_url("ftp://gw.example.com"), + Err(BaseUrlError::NotAUrl) + ); + assert_eq!(validate_base_url("https://"), Err(BaseUrlError::NotAUrl)); + } + + #[test] + fn effective_host_names_where_the_key_is_actually_sent() { + assert_eq!(effective_host(AiProvider::OpenAi, ""), "api.openai.com"); + assert_eq!( + effective_host(AiProvider::OpenAi, "https://gw.example.com/v1"), + "gw.example.com" + ); + // An override cannot redirect a provider that does not honour it, and + // the warning must not claim otherwise. + assert_eq!( + effective_host(AiProvider::Gemini, "https://gw.example.com/v1"), + "generativelanguage.googleapis.com" + ); + } +} diff --git a/crates/rgitui_ai/src/tools.rs b/crates/rgitui_ai/src/tools.rs index d82b911e..1a2b4b1b 100644 --- a/crates/rgitui_ai/src/tools.rs +++ b/crates/rgitui_ai/src/tools.rs @@ -4,6 +4,7 @@ //! to generate more accurate commit messages. use anyhow::Result; +use rgitui_git::git_command; use std::path::Path; /// Maximum number of commits to return for history tools. @@ -18,6 +19,113 @@ const MAX_DIFF_SIZE: usize = 100_000; /// Maximum directory depth for file tree. const MAX_TREE_DEPTH: usize = 5; +/// Total tool output a single generation may accumulate. +/// +/// The per-call caps above are not a budget: three iterations of `get_diff` +/// could add 300 KB on top of the base prompt. Once this is exhausted the +/// remaining calls are refused with a message the model can act on. +pub const MAX_TOOL_OUTPUT_BUDGET: usize = 200_000; + +/// Filenames that must never be uploaded to a third-party API, regardless of +/// how the model asks for them. +/// +/// The path-traversal check alone was not enough: `get_file_content("../.env")` +/// was correctly rejected, but `get_file_content(".env")` was accepted, and a +/// `DATABASE_URL=postgres://user:pass@…` went to the provider and was echoed +/// back into the conversation for every remaining iteration. There is no +/// consent step for that, so the only safe answer is not to read them. +const DENIED_FILE_NAMES: &[&str] = &[ + ".npmrc", + ".netrc", + "_netrc", + ".pgpass", + ".htpasswd", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", +]; + +/// Filename prefixes that are denied wherever they appear. +const DENIED_FILE_PREFIXES: &[&str] = &[".env", "credentials", "secrets", "id_rsa", "id_ed25519"]; + +/// Extensions that carry keys or certificates. +const DENIED_FILE_EXTENSIONS: &[&str] = &["pem", "key", "p12", "pfx", "jks", "keystore", "asc"]; + +/// Why a path was refused. Each maps to a sentence the model can act on rather +/// than a bare I/O error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeniedReason { + GitInternals, + Credentials, +} + +impl DeniedReason { + fn message(self, path: &str) -> String { + match self { + DeniedReason::GitInternals => format!( + "Refused: {} is inside .git/, which can contain remote URLs with embedded tokens.", + path + ), + DeniedReason::Credentials => format!( + "Refused: {} looks like a credentials file and is never sent to an AI provider.", + path + ), + } + } +} + +/// Whether a repo-relative path is one the AI must never read. +/// +/// Pure and case-insensitive, and it inspects every path component so a +/// denied file cannot be reached through a subdirectory. +pub fn denied_path(relative_path: &str) -> Option { + let normalized = relative_path.replace('\\', "/"); + let components: Vec<&str> = normalized + .split('/') + .filter(|part| !part.is_empty() && *part != ".") + .collect(); + + if components + .iter() + .any(|part| part.eq_ignore_ascii_case(".git")) + { + return Some(DeniedReason::GitInternals); + } + + let file_name = components.last()?.to_ascii_lowercase(); + + if DENIED_FILE_NAMES.contains(&file_name.as_str()) { + return Some(DeniedReason::Credentials); + } + if DENIED_FILE_PREFIXES + .iter() + .any(|prefix| file_name.starts_with(prefix)) + { + return Some(DeniedReason::Credentials); + } + if let Some((_, extension)) = file_name.rsplit_once('.') { + if DENIED_FILE_EXTENSIONS.contains(&extension) { + return Some(DeniedReason::Credentials); + } + } + + None +} + +/// Whether git ignores this path. A file the repository deliberately excludes +/// is not part of the change being described, and is the usual home for local +/// secrets that no denylist can enumerate. +fn is_git_ignored(repo_path: &Path, relative_path: &str) -> bool { + git_command() + .args(["check-ignore", "-q", "--no-index", "--"]) + .arg(relative_path) + .current_dir(repo_path) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + /// Truncate a string to at most `max` bytes without splitting a multi-byte /// UTF-8 character. Returns a prefix whose length is the largest char boundary /// at or below `max`, so slicing never panics on repo-controlled content. @@ -378,13 +486,73 @@ pub struct ToolCall { #[derive(Debug, Clone)] pub struct ToolResult { pub call_id: String, - pub name: String, pub result: Result, } +/// Tracks how much tool output a single generation has accumulated, so the +/// per-call caps add up to a bounded whole. +#[derive(Debug, Default)] +pub struct ToolBudget { + used: usize, +} + +impl ToolBudget { + pub fn new() -> Self { + Self::default() + } + + pub fn remaining(&self) -> usize { + MAX_TOOL_OUTPUT_BUDGET.saturating_sub(self.used) + } + + /// Charge `output` against the budget, trimming it to what is left. The + /// model is told when a result was cut short so it does not treat a + /// truncated listing as complete. + pub fn charge(&mut self, output: String) -> String { + let remaining = self.remaining(); + if remaining == 0 { + return "Tool output budget exhausted for this generation. Answer with what you \ + already have." + .to_string(); + } + if output.len() <= remaining { + self.used += output.len(); + return output; + } + self.used = MAX_TOOL_OUTPUT_BUDGET; + let trimmed = safe_truncate(&output, remaining); + let cut = trimmed.rfind('\n').unwrap_or(trimmed.len()); + format!( + "{}\n\n[tool output truncated -- the budget for this generation is spent]", + &trimmed[..cut] + ) + } +} + /// Execute a tool call and return the result. pub fn execute_tool(call: &ToolCall, repo_path: &Path) -> ToolResult { - let result = match call.name.as_str() { + execute_tool_within(call, repo_path, &mut ToolBudget::new()) +} + +/// Execute a tool call, charging its output against a per-generation budget. +pub fn execute_tool_within( + call: &ToolCall, + repo_path: &Path, + budget: &mut ToolBudget, +) -> ToolResult { + let result = match execute_tool_uncharged(call, repo_path) { + Ok(output) => Ok(budget.charge(output)), + Err(error) => Err(error), + }; + + ToolResult { + call_id: call.id.clone(), + result, + } +} + +fn execute_tool_uncharged(call: &ToolCall, repo_path: &Path) -> Result { + match call.name.as_str() { TOOL_GET_FILE_CONTENT => { let path = call.arguments["path"].as_str().unwrap_or(""); execute_get_file_content(repo_path, path) @@ -413,17 +581,25 @@ pub fn execute_tool(call: &ToolCall, repo_path: &Path) -> ToolResult { execute_get_file_tree(repo_path, path, max_depth.min(MAX_TREE_DEPTH)) } _ => Err(format!("Unknown tool: {}", call.name)), - }; - - ToolResult { - call_id: call.id.clone(), - name: call.name.clone(), - result, } } /// Get the content of a file in the repository. +/// +/// Denial happens before the read, in order: git internals, then the +/// credentials denylist, then git-ignored files, then the traversal check. +/// Only after all four does anything touch the file. fn execute_get_file_content(repo_path: &Path, relative_path: &str) -> Result { + if let Some(reason) = denied_path(relative_path) { + return Err(reason.message(relative_path)); + } + if is_git_ignored(repo_path, relative_path) { + return Err(format!( + "Refused: {} is git-ignored, so it is not part of the change and may hold local secrets.", + relative_path + )); + } + let file_path = repo_path.join(relative_path); // Security check: ensure path is within repo @@ -438,6 +614,15 @@ fn execute_get_file_content(repo_path: &Path, relative_path: &str) -> Result Result Result { - let output = std::process::Command::new("git") + let output = git_command() .args([ "log", &format!("-{}", count), @@ -485,7 +671,7 @@ fn execute_get_file_history( relative_path: &str, count: usize, ) -> Result { - let output = std::process::Command::new("git") + let output = git_command() .args([ "log", &format!("-{}", count), @@ -537,7 +723,7 @@ fn execute_get_diff(repo_path: &Path, kind: &str, commit: &str) -> Result Result Result { let diff = repo.diff_tree_to_index(head_tree.as_ref(), None, None)?; let mut text = String::new(); diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { - if let Ok(s) = std::str::from_utf8(line.content()) { - text.push(line.origin()); - text.push_str(s); - } + // Lossy rather than skip-on-error: a Latin-1 or Shift-JIS source file + // used to have its lines vanish from the diff with no marker, leaving + // the AI to reason about an incomplete change set and write a + // confidently wrong message. A mangled line is far better than a + // missing one. (Binary content is unaffected — libgit2 defaults to + // `show_binary = false` and renders "Binary files ... differ".) + text.push(line.origin()); + text.push_str(&String::from_utf8_lossy(line.content())); true })?; Ok(text) diff --git a/crates/rgitui_git/src/project/mod.rs b/crates/rgitui_git/src/project/mod.rs index fe1a467c..41059b3e 100644 --- a/crates/rgitui_git/src/project/mod.rs +++ b/crates/rgitui_git/src/project/mod.rs @@ -52,7 +52,7 @@ pub fn normalize_repo_path(path: PathBuf) -> PathBuf { /// Create a `git` [`Command`] with `CREATE_NO_WINDOW` set on Windows so that /// spawning it from a GUI application never flashes a visible console window. -pub(crate) fn git_command() -> Command { +pub fn git_command() -> Command { #[cfg(target_os = "windows")] { use std::os::windows::process::CommandExt; diff --git a/crates/rgitui_graph/src/lib.rs b/crates/rgitui_graph/src/lib.rs index 63ac30af..a164c3c7 100644 --- a/crates/rgitui_graph/src/lib.rs +++ b/crates/rgitui_graph/src/lib.rs @@ -751,6 +751,7 @@ impl GraphView { rgitui_ui::TextInputEvent::Submit => { this.jump_to_next_match(cx); } + rgitui_ui::TextInputEvent::Blurred => {} }, ) .detach(); diff --git a/crates/rgitui_settings/src/lib.rs b/crates/rgitui_settings/src/lib.rs index 9a6e4d57..a3aad009 100644 --- a/crates/rgitui_settings/src/lib.rs +++ b/crates/rgitui_settings/src/lib.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use gpui::{App, Global}; use keyring::Entry; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -126,6 +126,17 @@ pub enum AutoFetchInterval { ThirtyMinutes, } +impl AutoFetchInterval { + /// Every interval, in the order the settings UI lists them. + pub const ALL: &'static [AutoFetchInterval] = &[ + AutoFetchInterval::Disabled, + AutoFetchInterval::OneMinute, + AutoFetchInterval::FiveMinutes, + AutoFetchInterval::FifteenMinutes, + AutoFetchInterval::ThirtyMinutes, + ]; +} + impl fmt::Display for AutoFetchInterval { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -271,7 +282,7 @@ pub struct SavedWindowBounds { } /// Current settings version. Increment when making breaking changes. -const CURRENT_SETTINGS_VERSION: u32 = 2; +const CURRENT_SETTINGS_VERSION: u32 = 3; fn default_settings_version() -> u32 { CURRENT_SETTINGS_VERSION @@ -375,16 +386,163 @@ fn default_max_recent() -> usize { 20 } +/// The AI providers rgitui can talk to. +/// +/// Persisted by its lowercase id, and the single source of truth for endpoint +/// shape, auth style and default model. Dispatching on a bare string is what +/// let a hand-edited `settings.json` reach the network layer before failing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum AiProvider { + #[default] + Gemini, + #[serde(rename = "openai")] + OpenAi, + Anthropic, + #[serde(rename = "deepseek")] + DeepSeek, + #[serde(rename = "openrouter")] + OpenRouter, +} + +impl AiProvider { + /// Every provider, in the order the settings UI lists them. + pub const ALL: &'static [AiProvider] = &[ + AiProvider::Gemini, + AiProvider::OpenAi, + AiProvider::Anthropic, + AiProvider::DeepSeek, + AiProvider::OpenRouter, + ]; + + /// The stable id used in `settings.json`, keychain accounts and catalogue + /// cache filenames. Changing one needs a migration. + pub fn id(self) -> &'static str { + match self { + AiProvider::Gemini => "gemini", + AiProvider::OpenAi => "openai", + AiProvider::Anthropic => "anthropic", + AiProvider::DeepSeek => "deepseek", + AiProvider::OpenRouter => "openrouter", + } + } + + pub fn display_name(self) -> &'static str { + match self { + AiProvider::Gemini => "Google Gemini", + AiProvider::OpenAi => "OpenAI", + AiProvider::Anthropic => "Anthropic", + AiProvider::DeepSeek => "DeepSeek", + AiProvider::OpenRouter => "OpenRouter", + } + } + + /// Parse a persisted id. Case- and whitespace-insensitive, so a hand-edited + /// `"Anthropic"` resolves instead of silently falling back. + pub fn from_id(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + Self::ALL + .iter() + .copied() + .find(|provider| provider.id() == normalized) + } + + /// The GA model a fresh install (or a provider switch with no remembered + /// choice) uses: cheap, fast and tool-capable, because commit-message + /// generation does not need a frontier model. + pub fn default_model(self) -> &'static str { + match self { + AiProvider::Gemini => "gemini-3.1-flash-lite", + AiProvider::OpenAi => "gpt-5.6-luna", + AiProvider::Anthropic => "claude-haiku-4-5", + AiProvider::DeepSeek => "deepseek-v4-flash", + AiProvider::OpenRouter => "google/gemini-3.1-flash-lite", + } + } + + /// Where the user creates an API key for this provider. + pub fn key_url(self) -> &'static str { + match self { + AiProvider::Gemini => "https://aistudio.google.com/apikey", + AiProvider::OpenAi => "https://platform.openai.com/api-keys", + AiProvider::Anthropic => "https://console.anthropic.com/settings/keys", + AiProvider::DeepSeek => "https://platform.deepseek.com/api_keys", + AiProvider::OpenRouter => "https://openrouter.ai/keys", + } + } + + /// The host requests reach by default. Shown when warning about a + /// `base_url_override` so the user sees what they are replacing. + pub fn default_host(self) -> &'static str { + match self { + AiProvider::Gemini => "generativelanguage.googleapis.com", + AiProvider::OpenAi => "api.openai.com", + AiProvider::Anthropic => "api.anthropic.com", + AiProvider::DeepSeek => "api.deepseek.com", + AiProvider::OpenRouter => "openrouter.ai", + } + } + + /// Whether this provider speaks the OpenAI `/chat/completions` shape. + /// Only these honour `base_url_override`. + pub fn is_openai_compatible(self) -> bool { + matches!( + self, + AiProvider::OpenAi | AiProvider::DeepSeek | AiProvider::OpenRouter + ) + } +} + +impl fmt::Display for AiProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.display_name()) + } +} + +/// Deserialize a provider id leniently: an unknown value falls back to the +/// default rather than failing the whole settings file. [`init`] surfaces the +/// unknown value through `load_warnings` so the fallback is never silent. +fn deserialize_ai_provider<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + Ok(AiProvider::from_id(&raw).unwrap_or_default()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AiSettings { - #[serde(default = "default_ai_provider")] - pub provider: String, + #[serde( + default = "default_ai_provider", + deserialize_with = "deserialize_ai_provider" + )] + pub provider: AiProvider, #[serde(rename = "api_key", default, skip_serializing)] pub legacy_api_key: Option, + /// Whether the *active* provider holds a key. Derived from + /// `has_api_key_for`; retained so existing readers keep working. #[serde(default)] pub has_api_key: bool, + /// Which providers hold a key in the OS keychain. Flags only — never the + /// secret itself. + #[serde(default)] + pub has_api_key_for: BTreeMap, #[serde(default = "default_ai_model")] pub model: String, + /// Per-provider model pin, keyed by provider id. Preserves the user's + /// choice per provider instead of resetting it on every provider switch. + #[serde(default)] + pub models_by_provider: BTreeMap, + /// Override endpoint for OpenAI-compatible providers (LiteLLM, Ollama's + /// `/v1`, self-hosted gateways). Empty means use the built-in URL — the + /// default is deliberately not stored here so it cannot freeze at whatever + /// shipped. + #[serde(default)] + pub base_url_override: String, + /// Send `HTTP-Referer`/`X-Title` to OpenRouter for leaderboard attribution. + #[serde(default = "default_openrouter_attribution")] + pub openrouter_attribution: bool, #[serde(default = "default_commit_style")] pub commit_style: String, #[serde(default = "default_ai_enabled")] @@ -395,6 +553,70 @@ pub struct AiSettings { pub use_tools: bool, } +impl AiSettings { + /// The model pinned for `provider`, falling back to that provider's GA + /// default. Never returns another provider's model. + pub fn model_for(&self, provider: AiProvider) -> String { + if provider == self.provider && !self.model.trim().is_empty() { + return self.model.clone(); + } + self.models_by_provider + .get(provider.id()) + .filter(|model| !model.trim().is_empty()) + .cloned() + .unwrap_or_else(|| provider.default_model().to_string()) + } + + /// Whether `provider` has a key in the keychain, according to the persisted + /// flags. Does not touch the keychain. + pub fn has_key_for(&self, provider: AiProvider) -> bool { + self.has_api_key_for + .get(provider.id()) + .copied() + .unwrap_or(false) + } + + /// Record whether `provider` holds a key, keeping the active-provider + /// mirror `has_api_key` in step. + pub fn set_has_key_for(&mut self, provider: AiProvider, has_key: bool) { + self.has_api_key_for + .insert(provider.id().to_string(), has_key); + if provider == self.provider { + self.has_api_key = has_key; + } + } + + /// Point the active provider at `provider`, remembering the model the + /// previous provider used so switching back restores it. + pub fn set_active_provider(&mut self, provider: AiProvider) { + let previous = self.provider; + if !self.model.trim().is_empty() { + self.models_by_provider + .insert(previous.id().to_string(), self.model.clone()); + } + // Resolve the incoming model *before* reassigning `provider`, so + // `model_for`'s active-provider shortcut cannot hand back the model the + // previous provider was using. + let next_model = self + .models_by_provider + .get(provider.id()) + .filter(|model| !model.trim().is_empty()) + .cloned() + .unwrap_or_else(|| provider.default_model().to_string()); + self.provider = provider; + self.model = next_model; + self.has_api_key = self.has_key_for(provider); + } + + /// Pin `model` for the active provider. + pub fn set_active_model(&mut self, model: impl Into) { + let model = model.into(); + self.models_by_provider + .insert(self.provider.id().to_string(), model.clone()); + self.model = model; + } +} + fn default_use_tools() -> bool { true } @@ -407,12 +629,16 @@ fn default_inject_project_context() -> bool { true } -fn default_ai_provider() -> String { - "gemini".into() +fn default_openrouter_attribution() -> bool { + true +} + +fn default_ai_provider() -> AiProvider { + AiProvider::Gemini } fn default_ai_model() -> String { - "gemini-2.0-flash".into() + default_ai_provider().default_model().into() } fn default_commit_style() -> String { @@ -456,7 +682,9 @@ pub struct GitProviderSettings { #[derive(Debug, Clone, Default)] pub struct AuthRuntimeState { - pub ai_api_key: Option, + /// Resolved AI keys, keyed by provider id. One slot per provider so a + /// provider switch cannot transmit the previous provider's credential. + pub ai_api_keys: BTreeMap, pub git: GitAuthRuntime, } @@ -499,7 +727,11 @@ impl Default for AiSettings { provider: default_ai_provider(), legacy_api_key: None, has_api_key: false, + has_api_key_for: BTreeMap::new(), model: default_ai_model(), + models_by_provider: BTreeMap::new(), + base_url_override: String::new(), + openrouter_attribution: default_openrouter_attribution(), commit_style: default_commit_style(), enabled: true, inject_project_context: default_inject_project_context(), @@ -810,14 +1042,23 @@ impl SettingsState { log::info!("Migrated settings from version 1 to 2"); } - // Future migrations go here: - // Migration 1 -> 2: Example - // if self.settings.version == 1 { - // // Apply migration - // self.settings.version = 2; - // migrated = true; - // log::info!("Migrated settings from version 1 to 2"); - // } + // Migration 2 -> 3: AI keys became per-provider, and the shipped + // default model was a retired id absent from the picker. Remap the + // known-dead ids to their successors rather than leaving a pin that + // renders as no selection at all and 404s when used. + if self.settings.version == 2 { + if let Some(successor) = retired_model_successor(&self.settings.ai.model) { + log::info!( + "Remapping retired AI model '{}' to '{}'", + self.settings.ai.model, + successor + ); + self.settings.ai.model = successor.to_string(); + } + self.settings.version = 3; + migrated = true; + log::info!("Migrated settings from version 2 to 3"); + } // Ensure version is current if self.settings.version < CURRENT_SETTINGS_VERSION { @@ -833,8 +1074,28 @@ impl SettingsState { migrated } + /// The API key for the active AI provider. pub fn ai_api_key(&self) -> Option { - current_auth_runtime().ai_api_key + self.ai_api_key_for(self.settings.ai.provider) + } + + /// The API key stored for a specific provider, materialized only for the + /// caller that asked for it. + pub fn ai_api_key_for(&self, provider: AiProvider) -> Option { + with_auth_runtime(|runtime| runtime.ai_api_keys.get(provider.id()).cloned()) + } + + /// Whether the active provider has a key, without cloning any secret. + /// + /// Render paths must use this rather than `ai_api_key().is_some()`, which + /// deep-clones every credential the app holds on every frame. + pub fn has_ai_api_key(&self) -> bool { + self.settings.ai.has_key_for(self.settings.ai.provider) + } + + /// Whether `provider` has a key, without cloning any secret. + pub fn has_ai_api_key_for(&self, provider: AiProvider) -> bool { + self.settings.ai.has_key_for(provider) } pub fn git_https_token(&self) -> Option { @@ -850,9 +1111,20 @@ impl SettingsState { .and_then(|provider| provider.token) } + /// Store (or clear) the API key for the active provider. pub fn set_ai_api_key(&mut self, value: Option<&str>) -> Result<()> { + self.set_ai_api_key_for(self.settings.ai.provider, value) + } + + /// Store (or clear) the API key for a specific provider. + /// + /// The keychain write happens *before* any settings mutation, so a failed + /// write leaves the recorded flags and the resolved runtime key agreeing + /// with what is actually in the keychain. + pub fn set_ai_api_key_for(&mut self, provider: AiProvider, value: Option<&str>) -> Result<()> { + let has_key = write_secret(&ai_provider_account(provider.id()), value)?; self.settings.ai.legacy_api_key = None; - self.settings.ai.has_api_key = write_secret(AI_SECRET_ACCOUNT, value)?; + self.settings.ai.set_has_key_for(provider, has_key); sync_auth_runtime(&self.settings); Ok(()) } @@ -913,6 +1185,39 @@ impl SettingsState { } } + // Promote the single `ai/default` secret into the slot for whichever + // provider was active when it was written. `ai/default` is left in + // place so a downgrade still finds its key; a later save of that + // provider overwrites the new slot. Idempotent: the promotion is + // skipped once the per-provider slot exists. + if self.settings.ai.has_api_key { + let active = self.settings.ai.provider; + let account = ai_provider_account(active.id()); + if read_secret(&account).is_none() { + if let Some(key) = read_secret(AI_SECRET_ACCOUNT) { + if write_secret(&account, Some(&key))? { + self.settings.ai.set_has_key_for(active, true); + migrated = true; + log::info!( + "Promoted the shared AI key into the '{}' provider slot", + active.id() + ); + } + } + } + } + + // Re-derive the per-provider flags from what the keychain actually + // holds. A flag that says "connected" for a provider with no key is + // exactly the false-connected state the per-provider split fixes. + for provider in AiProvider::ALL { + let present = read_secret(&ai_provider_account(provider.id())).is_some(); + if self.settings.ai.has_key_for(*provider) != present { + self.settings.ai.set_has_key_for(*provider, present); + migrated = true; + } + } + if let Some(token) = self.settings.git.legacy_https_token.clone() { if !self.settings.git.has_https_token && write_secret(GIT_DEFAULT_HTTPS_ACCOUNT, Some(&token))? @@ -1082,7 +1387,30 @@ pub fn init(cx: &mut App) { let settings = if config_path.exists() { match std::fs::read_to_string(&config_path) { Ok(json) => match serde_json::from_str::(&json) { - Ok(settings) => settings, + Ok(settings) => { + // The provider deserializer falls back rather than failing + // the whole file, so re-read the raw value to tell the user + // which id was not understood. Without this the settings UI + // would simply show a different provider selected than the + // one in their file. + if let Some(raw) = raw_ai_provider(&json) { + if AiProvider::from_id(&raw).is_none() { + let msg = format!( + "Unknown AI provider \"{}\" in settings.json; using {}. Valid values: {}.", + raw, + settings.ai.provider.id(), + AiProvider::ALL + .iter() + .map(|provider| provider.id()) + .collect::>() + .join(", ") + ); + log::warn!("{}", msg); + load_warnings.push(msg); + } + } + settings + } Err(e) => { // Preserve the unparseable file so the user can recover any // hand-edited content instead of silently overwriting it with @@ -1151,6 +1479,17 @@ pub fn init(cx: &mut App) { cx.set_global(state); } +/// The raw `ai.provider` string as it appears on disk, before the lenient +/// deserializer has had a chance to substitute a fallback. +fn raw_ai_provider(json: &str) -> Option { + serde_json::from_str::(json) + .ok()? + .get("ai")? + .get("provider")? + .as_str() + .map(str::to_string) +} + /// Install default settings for a test app. /// /// Any view that reads `cx.global::()` in `render` needs this @@ -1167,13 +1506,21 @@ pub fn init_test(cx: &mut App) { }); } +/// Borrow the resolved credentials under the lock and return only what the +/// caller needs. +/// +/// Prefer this over [`current_auth_runtime`], which deep-clones every secret +/// the app holds — including ones the caller has no use for — into fresh heap +/// allocations that are dropped without zeroization. +pub fn with_auth_runtime(f: impl FnOnce(&AuthRuntimeState) -> R) -> R { + let guard = auth_runtime().read().expect( + "git auth runtime RwLock poisoned - a previous thread panicked while holding the lock", + ); + f(&guard) +} + pub fn current_auth_runtime() -> AuthRuntimeState { - auth_runtime() - .read() - .expect( - "git auth runtime RwLock poisoned - a previous thread panicked while holding the lock", - ) - .clone() + with_auth_runtime(|runtime| runtime.clone()) } pub fn current_git_auth_runtime() -> GitAuthRuntime { @@ -1229,6 +1576,8 @@ fn default_git_providers() -> Vec { } const KEYRING_SERVICE: &str = "rgitui"; +/// The pre-v3 single AI key slot. Retained so the v2 -> v3 migration can read +/// it and so a downgrade still finds a key; new writes never target it. const AI_SECRET_ACCOUNT: &str = "ai/default"; const GIT_DEFAULT_HTTPS_ACCOUNT: &str = "git/default-https"; @@ -1236,6 +1585,28 @@ fn git_provider_account(provider_id: &str) -> String { format!("git/provider/{}", provider_id) } +fn ai_provider_account(provider_id: &str) -> String { + format!("ai/provider/{}", provider_id) +} + +/// Model ids that are retired or fabricated, mapped to the successor a user +/// pinned to them should land on. Applied once, by the v2 -> v3 migration. +fn retired_model_successor(model: &str) -> Option<&'static str> { + match model.trim() { + // Retired 2026-06-01, and the shipped default that appeared in no + // picker, so a fresh install rendered the Model row with nothing + // selected. + "gemini-2.0-flash" | "gemini-1.5-flash" | "gemini-1.5-pro" => Some("gemini-3.1-flash-lite"), + // `20241022` is the Claude 3.5 snapshot date on a 4.5 name: a + // guaranteed 404 that was offered in the picker. + "claude-sonnet-4-5-20241022" => Some("claude-haiku-4-5"), + // o-series rejects `max_tokens` and a non-default `temperature`, and + // both are being retired. + "o3" | "o4-mini" | "o1" | "o1-mini" => Some("gpt-5.6-luna"), + _ => None, + } +} + fn auth_runtime() -> &'static RwLock { static AUTH_RUNTIME: OnceLock> = OnceLock::new(); AUTH_RUNTIME.get_or_init(|| RwLock::new(AuthRuntimeState::default())) @@ -1243,7 +1614,7 @@ fn auth_runtime() -> &'static RwLock { fn sync_auth_runtime(settings: &AppSettings) { let runtime = AuthRuntimeState { - ai_api_key: resolve_ai_api_key(&settings.ai), + ai_api_keys: resolve_ai_api_keys(&settings.ai), git: GitAuthRuntime { default_https_token: resolve_git_https_token(&settings.git), ssh_key_path: settings.git.ssh_key_path.as_ref().map(PathBuf::from), @@ -1271,12 +1642,36 @@ fn sync_auth_runtime(settings: &AppSettings) { ) = runtime; } -fn resolve_ai_api_key(settings: &AiSettings) -> Option { - if settings.has_api_key { - read_secret(AI_SECRET_ACCOUNT).or_else(|| settings.legacy_api_key.clone()) - } else { - settings.legacy_api_key.clone() +/// Resolve every provider's key from the keychain in one pass. +/// +/// Mirrors the git-provider loop: one read per provider that claims a key, and +/// none for the rest, so adding a provider does not multiply the cost of an +/// unrelated save. +fn resolve_ai_api_keys(settings: &AiSettings) -> BTreeMap { + let mut keys = BTreeMap::new(); + for provider in AiProvider::ALL { + if !settings.has_key_for(*provider) { + continue; + } + if let Some(secret) = read_secret(&ai_provider_account(provider.id())) { + keys.insert(provider.id().to_string(), secret); + } } + + // Pre-v3 files, and any install whose migration could not write to the + // keychain, still resolve through the shared slot for the active provider. + if !keys.contains_key(settings.provider.id()) { + let legacy = if settings.has_api_key { + read_secret(AI_SECRET_ACCOUNT).or_else(|| settings.legacy_api_key.clone()) + } else { + settings.legacy_api_key.clone() + }; + if let Some(secret) = legacy { + keys.insert(settings.provider.id().to_string(), secret); + } + } + + keys } fn resolve_git_https_token(settings: &GitSettings) -> Option { @@ -1349,6 +1744,199 @@ mod tests { } } + // ── AI provider catalogue coherence ─────────────────────────── + + /// The bug this guards is not hypothetical: the shipped default was + /// `gemini-2.0-flash`, which appeared in no picker, so a fresh install + /// rendered the Model row with nothing selected at all. + #[test] + fn default_model_is_the_default_provider_model() { + assert_eq!(default_ai_model(), default_ai_provider().default_model()); + } + + #[test] + fn every_provider_has_a_distinct_id_and_a_default_model() { + let mut ids: Vec<&str> = AiProvider::ALL.iter().map(|p| p.id()).collect(); + let count = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), count, "provider ids must be unique"); + + for provider in AiProvider::ALL { + assert!(!provider.default_model().is_empty()); + assert!(provider.key_url().starts_with("https://")); + assert!(!provider.display_name().is_empty()); + assert_eq!(AiProvider::from_id(provider.id()), Some(*provider)); + } + } + + #[test] + fn no_default_model_is_a_retired_id() { + for provider in AiProvider::ALL { + assert_eq!( + retired_model_successor(provider.default_model()), + None, + "{} ships a retired default", + provider.id() + ); + } + } + + #[test] + fn provider_id_parsing_is_lenient_about_case_and_whitespace() { + assert_eq!( + AiProvider::from_id("Anthropic"), + Some(AiProvider::Anthropic) + ); + assert_eq!(AiProvider::from_id(" OpenAI "), Some(AiProvider::OpenAi)); + assert_eq!( + AiProvider::from_id("openrouter"), + Some(AiProvider::OpenRouter) + ); + assert_eq!(AiProvider::from_id("bard"), None); + } + + #[test] + fn only_openai_compatible_providers_accept_a_base_url_override() { + assert!(AiProvider::OpenAi.is_openai_compatible()); + assert!(AiProvider::DeepSeek.is_openai_compatible()); + assert!(AiProvider::OpenRouter.is_openai_compatible()); + assert!(!AiProvider::Gemini.is_openai_compatible()); + assert!(!AiProvider::Anthropic.is_openai_compatible()); + } + + // ── AiSettings model memory ─────────────────────────────────── + + #[test] + fn switching_provider_preserves_each_providers_model_choice() { + let mut ai = AiSettings::default(); + ai.set_active_model("gemini-3.1-pro-preview"); + + ai.set_active_provider(AiProvider::OpenAi); + assert_eq!(ai.model, AiProvider::OpenAi.default_model()); + ai.set_active_model("gpt-5.4"); + + ai.set_active_provider(AiProvider::Gemini); + assert_eq!(ai.model, "gemini-3.1-pro-preview"); + + ai.set_active_provider(AiProvider::OpenAi); + assert_eq!(ai.model, "gpt-5.4"); + } + + #[test] + fn model_for_never_returns_another_providers_model() { + let mut ai = AiSettings::default(); + ai.set_active_model("gemini-3.1-pro-preview"); + assert_eq!( + ai.model_for(AiProvider::Anthropic), + AiProvider::Anthropic.default_model() + ); + } + + #[test] + fn key_flags_are_tracked_per_provider() { + let mut ai = AiSettings::default(); + ai.set_has_key_for(AiProvider::Gemini, true); + assert!(ai.has_key_for(AiProvider::Gemini)); + assert!(ai.has_api_key, "the active provider mirror must follow"); + assert!(!ai.has_key_for(AiProvider::Anthropic)); + + // Switching to a provider with no key must not keep asserting + // "connected" — that false state is what enabled the AI button for a + // provider the app had no credential for. + ai.set_active_provider(AiProvider::Anthropic); + assert!(!ai.has_api_key); + } + + // ── settings file compatibility ─────────────────────────────── + + #[test] + fn v2_settings_load_with_every_new_field_defaulted() { + let json = r#"{ + "version": 2, + "ai": { "provider": "openai", "model": "gpt-5.4", "has_api_key": true } + }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::OpenAi); + assert_eq!(settings.ai.model, "gpt-5.4"); + assert!(settings.ai.has_api_key_for.is_empty()); + assert!(settings.ai.models_by_provider.is_empty()); + assert!(settings.ai.base_url_override.is_empty()); + assert!(settings.ai.openrouter_attribution); + } + + #[test] + fn an_unknown_provider_falls_back_instead_of_failing_the_file() { + let json = r#"{ "version": 3, "ai": { "provider": "bard" } }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::default()); + // And the raw value is still recoverable, which is what lets `init` + // tell the user which id it did not understand. + assert_eq!(raw_ai_provider(json).as_deref(), Some("bard")); + } + + #[test] + fn a_miscased_provider_resolves_rather_than_falling_back() { + let json = r#"{ "ai": { "provider": "Anthropic" } }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::Anthropic); + } + + #[test] + fn the_api_key_never_reaches_the_settings_file() { + let mut settings = AppSettings::default(); + settings.ai.legacy_api_key = Some("sk-should-never-be-written".into()); + let json = serde_json::to_string(&settings).unwrap(); + assert!(!json.contains("sk-should-never-be-written")); + } + + #[test] + fn v2_to_v3_remaps_retired_model_ids() { + let mut state = test_settings_state(); + state.settings.version = 2; + state.settings.ai.model = "gemini-2.0-flash".into(); + + assert!(state.migrate_settings()); + + assert_eq!(state.settings.version, CURRENT_SETTINGS_VERSION); + assert_eq!(state.settings.ai.model, "gemini-3.1-flash-lite"); + } + + #[test] + fn v2_to_v3_leaves_a_live_model_alone() { + let mut state = test_settings_state(); + state.settings.version = 2; + state.settings.ai.model = "gemini-2.5-pro".into(); + + state.migrate_settings(); + + assert_eq!(state.settings.ai.model, "gemini-2.5-pro"); + } + + #[test] + fn retired_ids_map_to_a_successor_of_the_same_provider_family() { + assert_eq!( + retired_model_successor("claude-sonnet-4-5-20241022"), + Some("claude-haiku-4-5") + ); + assert_eq!(retired_model_successor("o4-mini"), Some("gpt-5.6-luna")); + assert_eq!(retired_model_successor("deepseek-v4-flash"), None); + } + + #[test] + fn ai_provider_accounts_are_distinct_and_namespaced() { + let accounts: Vec = AiProvider::ALL + .iter() + .map(|p| ai_provider_account(p.id())) + .collect(); + assert_eq!(accounts[0], "ai/provider/gemini"); + for account in &accounts { + assert!(account.starts_with("ai/provider/")); + assert_ne!(account, AI_SECRET_ACCOUNT); + assert!(!account.starts_with("git/")); + } + } + #[test] fn migrates_legacy_last_workspace_into_workspace_snapshot() { let mut state = test_settings_state(); diff --git a/crates/rgitui_ui/src/fuzzy.rs b/crates/rgitui_ui/src/fuzzy.rs new file mode 100644 index 00000000..5a878f5a --- /dev/null +++ b/crates/rgitui_ui/src/fuzzy.rs @@ -0,0 +1,98 @@ +//! Fuzzy subsequence matching, shared by every search box in the app. +//! +//! One implementation, so the command palette and the model picker cannot +//! drift into ranking the same query differently. + +/// Score `query` against `target`, or `None` when the query's characters do +/// not all appear in `target` in order. +/// +/// Higher is better. Matches nearer the start of the target score higher, so +/// typing a prefix surfaces the obvious candidate first. +pub fn fuzzy_score(query: &str, target: &str) -> Option { + if query.is_empty() { + return Some(0); + } + let target_len = target.len(); + let mut score: usize = 0; + let mut target_chars = target.char_indices(); + // Callers usually lowercase both sides already; doing it here as well keeps + // direct calls correct. + for query_char in query.to_lowercase().chars() { + loop { + match target_chars.next() { + Some((pos, target_char)) => { + if target_char.to_ascii_lowercase() == query_char { + score += target_len.saturating_sub(pos); + break; + } + } + None => return None, + } + } + } + Some(score) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_exact_match_scores() { + assert!(fuzzy_score("commit", "commit").is_some()); + } + + #[test] + fn matching_is_case_insensitive() { + assert!(fuzzy_score("COMMIT", "commit").is_some()); + assert!(fuzzy_score("commit", "COMMIT").is_some()); + } + + #[test] + fn every_query_character_must_appear_in_order() { + assert!(fuzzy_score("abc", "axbxc").is_some()); + assert!(fuzzy_score("cba", "axbxc").is_none()); + assert!(fuzzy_score("xyz", "commit").is_none()); + } + + #[test] + fn an_empty_query_matches_anything_with_no_preference() { + assert_eq!(fuzzy_score("", "anything"), Some(0)); + assert_eq!(fuzzy_score("", ""), Some(0)); + } + + #[test] + fn an_empty_target_matches_only_an_empty_query() { + assert_eq!(fuzzy_score("a", ""), None); + } + + #[test] + fn an_earlier_match_scores_higher() { + let early = fuzzy_score("g", "gemini").unwrap(); + let late = fuzzy_score("g", "openai/g").unwrap(); + assert!(early > late); + } + + #[test] + fn a_query_longer_than_its_target_cannot_match() { + assert_eq!(fuzzy_score("commit-message", "commit"), None); + } + + #[test] + fn repeated_characters_consume_distinct_positions() { + assert!(fuzzy_score("aa", "aa").is_some()); + assert!(fuzzy_score("aa", "a").is_none()); + } + + #[test] + fn digits_punctuation_and_unicode_all_match() { + assert!(fuzzy_score("gpt5", "gpt-5.6-luna").is_some()); + assert!(fuzzy_score("a/b", "a/b/c").is_some()); + assert!(fuzzy_score("é", "café").is_some()); + } + + #[test] + fn a_multibyte_target_does_not_panic_on_a_missing_character() { + assert_eq!(fuzzy_score("z", "日本語"), None); + } +} diff --git a/crates/rgitui_ui/src/lib.rs b/crates/rgitui_ui/src/lib.rs index cdd675a3..a595345b 100644 --- a/crates/rgitui_ui/src/lib.rs +++ b/crates/rgitui_ui/src/lib.rs @@ -6,15 +6,19 @@ mod context_menu; mod diff_stat; mod disclosure; mod divider; +mod fuzzy; mod icon; mod indicator; mod label; mod list_item; mod modal; +mod picker; mod scroll_list; mod scrollbar; +mod select; mod spinner; mod stack; +mod status_pill; mod tab_bar; mod text_input; mod toast; @@ -29,15 +33,19 @@ pub use context_menu::*; pub use diff_stat::*; pub use disclosure::*; pub use divider::*; +pub use fuzzy::*; pub use icon::*; pub use indicator::*; pub use label::*; pub use list_item::*; pub use modal::*; +pub use picker::*; pub use scroll_list::*; pub use scrollbar::*; +pub use select::*; pub use spinner::*; pub use stack::*; +pub use status_pill::*; pub use tab_bar::*; pub use text_input::*; pub use toast::*; diff --git a/crates/rgitui_ui/src/picker.rs b/crates/rgitui_ui/src/picker.rs new file mode 100644 index 00000000..a5d91a69 --- /dev/null +++ b/crates/rgitui_ui/src/picker.rs @@ -0,0 +1,627 @@ +//! A searchable, virtualized picker overlay. +//! +//! Used for the model list, where a closed pill row cannot work: OpenRouter +//! alone offers hundreds of models, and the two questions that actually decide +//! the choice — "is it cheap" and "does it take tools" — are facets, not +//! substrings. +//! +//! Filtering and ranking are pure functions over `[PickerRow]` so they are +//! testable without a display, per the convention in `CLAUDE.md`. + +use gpui::prelude::*; +use gpui::{ + div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, + Focusable, FontWeight, KeyDownEvent, Render, ScrollStrategy, SharedString, + UniformListScrollHandle, Window, +}; +use rgitui_theme::{ActiveTheme, Color, StyledExt}; + +use crate::{fuzzy_score, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent}; + +/// One selectable row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerRow { + pub id: SharedString, + pub primary: SharedString, + pub secondary: Option, + /// Right-hand column, e.g. `1M $0.30/$2.50`. + pub trailing: Option, + pub badges: Vec, + /// Which filter chips this row belongs to. + pub facets: Vec, + /// Rendered above the list and always selectable, even when filtered out. + /// A pinned model that has left the catalogue must stay reachable. + pub pinned_note: Option, +} + +impl PickerRow { + pub fn new(id: impl Into, primary: impl Into) -> Self { + Self { + id: id.into(), + primary: primary.into(), + secondary: None, + trailing: None, + badges: Vec::new(), + facets: Vec::new(), + pinned_note: None, + } + } + + pub fn secondary(mut self, secondary: impl Into) -> Self { + self.secondary = Some(secondary.into()); + self + } + + pub fn trailing(mut self, trailing: impl Into) -> Self { + self.trailing = Some(trailing.into()); + self + } + + pub fn badge(mut self, badge: impl Into) -> Self { + self.badges.push(badge.into()); + self + } + + pub fn facet(mut self, facet: impl Into) -> Self { + self.facets.push(facet.into()); + self + } + + pub fn pinned_note(mut self, note: impl Into) -> Self { + self.pinned_note = Some(note.into()); + self + } +} + +/// A filter chip above the list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerChip { + pub id: SharedString, + pub label: SharedString, +} + +impl PickerChip { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + } + } +} + +#[derive(Debug, Clone)] +pub enum PickerEvent { + Selected(SharedString), + Dismissed, + RefreshRequested, + /// The active filter chip changed. The owner re-supplies rows. + ChipChanged(SharedString), +} + +/// Row height, in pixels. Two lines of text plus padding. +const ROW_HEIGHT: f32 = 44.0; + +/// Rank rows against a query. Pure. +/// +/// An empty query preserves the caller's order, which for a server-sorted +/// catalogue is already the most useful ranking there is. +pub fn rank_rows<'a>(rows: &'a [PickerRow], query: &str) -> Vec<&'a PickerRow> { + let query = query.trim(); + if query.is_empty() { + return rows.iter().collect(); + } + let mut scored: Vec<(usize, &PickerRow)> = rows + .iter() + .filter_map(|row| { + let score = fuzzy_score(query, &row.id) + .into_iter() + .chain(fuzzy_score(query, &row.primary)) + .max()?; + Some((score, row)) + }) + .collect(); + // Descending: the best score first. + scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + scored.into_iter().map(|(_, row)| row).collect() +} + +/// Keep only rows carrying `facet`. An empty facet keeps everything, which is +/// what the "All" chip means. +pub fn rows_in_facet<'a>(rows: &'a [&'a PickerRow], facet: &str) -> Vec<&'a PickerRow> { + if facet.is_empty() { + return rows.to_vec(); + } + rows.iter() + .copied() + .filter(|row| row.facets.iter().any(|value| value == facet)) + .collect() +} + +pub struct Picker { + rows: Vec, + chips: Vec, + active_chip: SharedString, + selected_id: Option, + highlighted: usize, + query_editor: gpui::Entity, + scroll_handle: UniformListScrollHandle, + focus_handle: FocusHandle, + footer_note: Option, + status_note: Option, +} + +impl EventEmitter for Picker {} + +impl Focusable for Picker { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Picker { + pub fn new(cx: &mut Context) -> Self { + let query_editor = cx.new(|cx| { + let mut input = TextInput::new(cx); + input.set_placeholder("Search models…"); + input + }); + + cx.subscribe( + &query_editor, + |this: &mut Self, _, event: &TextInputEvent, cx| match event { + TextInputEvent::Changed(_) => { + this.highlighted = 0; + this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + cx.notify(); + } + TextInputEvent::Submit => this.commit_highlighted(cx), + TextInputEvent::Blurred => {} + }, + ) + .detach(); + + Self { + rows: Vec::new(), + chips: Vec::new(), + active_chip: SharedString::default(), + selected_id: None, + highlighted: 0, + query_editor, + scroll_handle: UniformListScrollHandle::new(), + focus_handle: cx.focus_handle(), + footer_note: None, + status_note: None, + } + } + + pub fn set_rows(&mut self, rows: Vec, cx: &mut Context) { + self.rows = rows; + self.highlighted = 0; + cx.notify(); + } + + pub fn set_chips(&mut self, chips: Vec, cx: &mut Context) { + self.chips = chips; + cx.notify(); + } + + pub fn set_active_chip(&mut self, chip: impl Into, cx: &mut Context) { + self.active_chip = chip.into(); + self.highlighted = 0; + cx.notify(); + } + + pub fn set_selected(&mut self, id: Option, cx: &mut Context) { + self.selected_id = id; + cx.notify(); + } + + /// A short line in the footer, e.g. `312 models · updated 3 h ago`. + pub fn set_footer_note(&mut self, note: Option, cx: &mut Context) { + self.footer_note = note; + cx.notify(); + } + + /// A warning shown above the list — a stale catalogue, or a fetch that + /// failed. Never blanks the list. + pub fn set_status_note(&mut self, note: Option, cx: &mut Context) { + self.status_note = note; + cx.notify(); + } + + pub fn focus(&self, window: &mut Window, cx: &mut Context) { + self.query_editor + .update(cx, |input, cx| input.focus(window, cx)); + } + + pub fn clear_query(&mut self, cx: &mut Context) { + self.query_editor.update(cx, |input, cx| input.clear(cx)); + self.highlighted = 0; + cx.notify(); + } + + /// The rows currently visible, after chip and query filtering. + fn visible_rows(&self, cx: &Context) -> Vec { + let query = self.query_editor.read(cx).text().to_string(); + let ranked = rank_rows(&self.rows, &query); + rows_in_facet(&ranked, &self.active_chip) + .into_iter() + .cloned() + .collect() + } + + fn commit_highlighted(&mut self, cx: &mut Context) { + let visible = self.visible_rows(cx); + let Some(row) = visible.get(self.highlighted) else { + return; + }; + let id = row.id.clone(); + self.selected_id = Some(id.clone()); + cx.emit(PickerEvent::Selected(id)); + cx.notify(); + } + + fn move_highlight(&mut self, delta: isize, cx: &mut Context) { + let count = self.visible_rows(cx).len(); + if count == 0 { + return; + } + let next = (self.highlighted as isize + delta).clamp(0, count as isize - 1) as usize; + self.highlighted = next; + self.scroll_handle + .scroll_to_item(next, ScrollStrategy::Center); + cx.notify(); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + match event.keystroke.key.as_str() { + "escape" => cx.emit(PickerEvent::Dismissed), + "down" => self.move_highlight(1, cx), + "up" => self.move_highlight(-1, cx), + "pagedown" => self.move_highlight(8, cx), + "pageup" => self.move_highlight(-8, cx), + "enter" => self.commit_highlighted(cx), + _ => return, + } + cx.stop_propagation(); + } +} + +impl Render for Picker { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); + let visible = self.visible_rows(cx); + let total = self.rows.len(); + let shown = visible.len(); + let highlighted = self.highlighted; + let selected_id = self.selected_id.clone(); + + let mut chip_row = div() + .flex() + .flex_row() + // Wrapping is not optional here: a fixed row of chips is exactly + // what made the old model pills overflow the window. + .flex_wrap() + .items_start() + .gap(px(4.)) + .px(px(10.)) + .py(px(6.)); + for chip in &self.chips { + let is_active = chip.id == self.active_chip; + let chip_id = chip.id.clone(); + chip_row = chip_row.child( + div() + .id(ElementId::Name(format!("picker-chip-{}", chip.id).into())) + .flex() + .flex_row() + .items_center() + .h(px(24.)) + .px(px(10.)) + .rounded(px(12.)) + .cursor_pointer() + .bg(if is_active { + colors.element_selected + } else { + colors.element_background + }) + .hover(|style| style.bg(colors.ghost_element_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.set_active_chip(chip_id.clone(), cx); + cx.emit(PickerEvent::ChipChanged(chip_id.clone())); + })) + .child( + Label::new(chip.label.clone()) + .size(LabelSize::XSmall) + .color(if is_active { + Color::Default + } else { + Color::Muted + }) + .weight(if is_active { + FontWeight::SEMIBOLD + } else { + FontWeight::NORMAL + }), + ), + ); + } + + let rows_for_list = visible.clone(); + let list = uniform_list( + "picker-rows", + shown, + cx.processor(move |_this, range: std::ops::Range, _window, cx| { + let colors = cx.colors().clone(); + range + .map(|index| { + let Some(row) = rows_for_list.get(index) else { + return div().h(px(ROW_HEIGHT)).into_any_element(); + }; + let is_highlighted = index == highlighted; + let is_selected = selected_id.as_ref() == Some(&row.id); + let row_id = row.id.clone(); + + let mut badges = div().flex().flex_row().gap(px(4.)); + for badge in &row.badges { + badges = badges.child( + div() + .px(px(5.)) + .rounded(px(3.)) + .bg(colors.element_background) + .child( + Label::new(badge.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ); + } + + div() + .id(ElementId::NamedInteger("picker-row".into(), index as u64)) + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .h(px(ROW_HEIGHT)) + .px(px(10.)) + .cursor_pointer() + .when(is_highlighted, |el| el.bg(colors.element_selected)) + .hover(|style| style.bg(colors.ghost_element_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.selected_id = Some(row_id.clone()); + cx.emit(PickerEvent::Selected(row_id.clone())); + cx.notify(); + })) + .child(div().w(px(14.)).flex_shrink_0().when(is_selected, |el| { + el.child( + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(Color::Accent), + ) + })) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + Label::new(row.primary.clone()) + .size(LabelSize::Small) + .color(Color::Default), + ) + .when_some(row.secondary.clone(), |el, secondary| { + el.child( + Label::new(secondary) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }), + ) + .child(badges) + .when_some(row.trailing.clone(), |el, trailing| { + el.child( + div().flex_shrink_0().child( + Label::new(trailing) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + }) + .into_any_element() + }) + .collect() + }), + ) + .track_scroll(&self.scroll_handle) + .flex_1() + .min_h(px(0.)); + + div() + .id("model-picker") + .track_focus(&self.focus_handle) + .flex() + .flex_col() + .w_full() + .max_h(px(420.)) + .rounded(px(8.)) + .border_1() + .border_color(colors.border) + .bg(colors.elevated_surface_background) + .elevation_2(cx) + .on_key_down(cx.listener(Self::on_key_down)) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .px(px(10.)) + .py(px(8.)) + .border_b_1() + .border_color(colors.border_variant) + .child( + Icon::new(IconName::Search) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(div().flex_1().min_w_0().child(self.query_editor.clone())) + .child( + crate::IconButton::new("picker-refresh", IconName::Refresh) + .size(crate::ButtonSize::Compact) + .color(Color::Muted) + .tooltip("Refresh the model list") + .on_click(cx.listener(|_this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + cx.emit(PickerEvent::RefreshRequested); + })), + ), + ) + .when(!self.chips.is_empty(), |el| el.child(chip_row)) + // A failed refresh shows a note; it never blanks the list, because + // a stale list is far more use than an empty one. + .when_some(self.status_note.clone(), |el, note| { + el.child( + div().px(px(10.)).pb(px(4.)).child( + Label::new(note) + .size(LabelSize::XSmall) + .color(Color::Warning), + ), + ) + }) + .child(if shown == 0 { + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .h(px(80.)) + .child( + Label::new("No models match this search") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + } else { + list.into_any_element() + }) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(10.)) + .px(px(10.)) + .py(px(6.)) + .border_t_1() + .border_color(colors.border_variant) + .child( + Label::new("↑↓ navigate ⏎ select esc cancel") + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child(div().flex_1()) + .child( + Label::new(match &self.footer_note { + Some(note) => format!("{shown} of {total} · {note}"), + None => format!("{shown} of {total}"), + }) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows() -> Vec { + vec![ + PickerRow::new("google/gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite") + .facet("cheap") + .facet("tools"), + PickerRow::new("openai/gpt-5.6-luna", "GPT-5.6 Luna") + .facet("cheap") + .facet("tools"), + PickerRow::new("anthropic/claude-opus-4-6", "Claude Opus 4.6").facet("tools"), + ] + } + + #[test] + fn an_empty_query_preserves_the_supplied_order() { + let rows = rows(); + let ranked = rank_rows(&rows, ""); + assert_eq!(ranked.len(), 3); + assert_eq!(ranked[0].id, rows[0].id); + assert_eq!(ranked[2].id, rows[2].id); + } + + #[test] + fn a_query_ranks_matches_and_drops_non_matches() { + let rows = rows(); + let ranked = rank_rows(&rows, "flash"); + assert_eq!(ranked.len(), 1); + assert!(ranked[0].id.contains("flash")); + } + + #[test] + fn a_query_matches_the_display_name_as_well_as_the_id() { + let rows = rows(); + let ranked = rank_rows(&rows, "Opus"); + assert_eq!(ranked.len(), 1); + assert_eq!(ranked[0].id, "anthropic/claude-opus-4-6"); + } + + #[test] + fn a_query_that_matches_nothing_yields_an_empty_list_not_everything() { + assert!(rank_rows(&rows(), "zzzzzz").is_empty()); + } + + #[test] + fn ranking_is_case_insensitive() { + assert_eq!(rank_rows(&rows(), "FLASH").len(), 1); + } + + #[test] + fn an_empty_chip_means_all_rows() { + let rows = rows(); + let all: Vec<&PickerRow> = rows.iter().collect(); + assert_eq!(rows_in_facet(&all, "").len(), 3); + } + + #[test] + fn a_chip_keeps_only_rows_carrying_that_facet() { + let rows = rows(); + let all: Vec<&PickerRow> = rows.iter().collect(); + assert_eq!(rows_in_facet(&all, "cheap").len(), 2); + assert_eq!(rows_in_facet(&all, "tools").len(), 3); + assert_eq!(rows_in_facet(&all, "free").len(), 0); + } + + #[test] + fn chip_and_query_filtering_compose() { + let rows = rows(); + let ranked = rank_rows(&rows, "gpt"); + let filtered = rows_in_facet(&ranked, "cheap"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "openai/gpt-5.6-luna"); + } + + #[test] + fn row_builders_compose() { + let row = PickerRow::new("id", "Primary") + .secondary("Google · fast") + .trailing("1M $0.25/$1.50") + .badge("tools") + .facet("cheap") + .pinned_note("no longer offered"); + assert_eq!(row.secondary.as_deref(), Some("Google · fast")); + assert_eq!(row.badges, vec![SharedString::from("tools")]); + assert_eq!(row.facets, vec![SharedString::from("cheap")]); + assert_eq!(row.pinned_note.as_deref(), Some("no longer offered")); + } +} diff --git a/crates/rgitui_ui/src/select.rs b/crates/rgitui_ui/src/select.rs new file mode 100644 index 00000000..916eaf2f --- /dev/null +++ b/crates/rgitui_ui/src/select.rs @@ -0,0 +1,509 @@ +//! A dropdown select. +//! +//! The component library had no Select, no Combobox and no searchable picker, +//! so every closed choice in settings was rendered as a row of pills — which +//! is why a four-item model list overflowed the default window. +//! +//! State (open/closed, highlighted row) is owned here, because a stateless +//! popover cannot support keyboard navigation, and the settings page had no +//! keyboard support at all. + +use gpui::prelude::*; +use gpui::{ + anchored, deferred, div, px, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, + Focusable, KeyDownEvent, Render, SharedString, StyleRefinement, Window, +}; +use rgitui_theme::{ActiveTheme, Color, StyledExt}; + +use crate::{Icon, IconName, IconSize, Label, LabelSize}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectOption { + pub id: SharedString, + pub label: SharedString, + /// Second line, rendered muted. + pub detail: Option, + pub icon: Option, + pub disabled: bool, +} + +impl SelectOption { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + detail: None, + icon: None, + disabled: false, + } + } + + pub fn detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +#[derive(Debug, Clone)] +pub enum SelectEvent { + Changed(SharedString), + Dismissed, +} + +/// Move a highlight by `delta`, skipping disabled rows so Enter can never +/// commit one, and clamping at both ends rather than wrapping. +/// +/// Pure, so the whole keyboard-navigation contract is testable without a +/// display. +pub fn next_enabled_option(options: &[SelectOption], from: usize, delta: isize) -> usize { + if options.is_empty() { + return 0; + } + let len = options.len() as isize; + let mut index = from as isize; + for _ in 0..len { + index = (index + delta).clamp(0, len - 1); + if !options[index as usize].disabled { + return index as usize; + } + // Every remaining candidate in this direction is disabled. + if index == 0 || index == len - 1 { + break; + } + } + from.min(options.len().saturating_sub(1)) +} + +pub struct Select { + id: ElementId, + options: Vec, + selected: Option, + placeholder: SharedString, + open: bool, + highlighted: usize, + full_width: bool, + disabled: bool, + tab_index: isize, + focus_handle: FocusHandle, +} + +impl EventEmitter for Select {} + +impl Focusable for Select { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Select { + pub fn new(id: impl Into, cx: &mut Context) -> Self { + Self { + id: id.into(), + options: Vec::new(), + selected: None, + placeholder: "Select…".into(), + open: false, + highlighted: 0, + full_width: false, + disabled: false, + tab_index: 0, + focus_handle: cx.focus_handle(), + } + } + + pub fn set_options(&mut self, options: Vec, cx: &mut Context) { + self.options = options; + self.highlighted = self.selected_index().unwrap_or(0); + cx.notify(); + } + + pub fn set_selected(&mut self, id: Option, cx: &mut Context) { + self.selected = id; + self.highlighted = self.selected_index().unwrap_or(0); + cx.notify(); + } + + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + cx: &mut Context, + ) { + self.placeholder = placeholder.into(); + cx.notify(); + } + + pub fn set_full_width(&mut self, full_width: bool, cx: &mut Context) { + self.full_width = full_width; + cx.notify(); + } + + pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { + self.disabled = disabled; + if disabled { + self.open = false; + } + cx.notify(); + } + + pub fn set_tab_index(&mut self, tab_index: isize, cx: &mut Context) { + self.tab_index = tab_index; + cx.notify(); + } + + pub fn selected(&self) -> Option<&SharedString> { + self.selected.as_ref() + } + + pub fn is_open(&self) -> bool { + self.open + } + + fn selected_index(&self) -> Option { + let selected = self.selected.as_ref()?; + self.options + .iter() + .position(|option| &option.id == selected) + } + + /// The text shown on the closed trigger. + fn trigger_label(&self) -> SharedString { + // A selection that is no longer in the option list still shows its raw + // id rather than reverting to the placeholder: a pinned model that has + // left the catalogue must stay visible and changeable. + match self.selected_index() { + Some(index) => self.options[index].label.clone(), + None => self + .selected + .clone() + .filter(|selected| !selected.is_empty()) + .unwrap_or_else(|| self.placeholder.clone()), + } + } + + pub fn toggle(&mut self, cx: &mut Context) { + if self.disabled { + return; + } + self.open = !self.open; + if self.open { + self.highlighted = self.selected_index().unwrap_or(0); + } else { + cx.emit(SelectEvent::Dismissed); + } + cx.notify(); + } + + fn close(&mut self, cx: &mut Context) { + if !self.open { + return; + } + self.open = false; + cx.emit(SelectEvent::Dismissed); + cx.notify(); + } + + fn commit(&mut self, index: usize, cx: &mut Context) { + let Some(option) = self.options.get(index) else { + return; + }; + if option.disabled { + return; + } + let id = option.id.clone(); + self.selected = Some(id.clone()); + self.open = false; + cx.emit(SelectEvent::Changed(id)); + cx.notify(); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + let key = event.keystroke.key.as_str(); + if !self.open { + if matches!(key, "enter" | "space" | "down") { + self.toggle(cx); + cx.stop_propagation(); + } + return; + } + + match key { + "escape" => self.close(cx), + "enter" => { + let index = self.highlighted; + self.commit(index, cx); + } + "down" => { + self.highlighted = next_enabled_option(&self.options, self.highlighted, 1); + cx.notify(); + } + "up" => { + self.highlighted = next_enabled_option(&self.options, self.highlighted, -1); + cx.notify(); + } + "home" => { + self.highlighted = next_enabled_option(&self.options, 0, 0) + .min(self.options.len().saturating_sub(1)); + if self + .options + .get(self.highlighted) + .is_some_and(|option| option.disabled) + { + self.highlighted = next_enabled_option(&self.options, 0, 1); + } + cx.notify(); + } + "end" => { + let last = self.options.len().saturating_sub(1); + self.highlighted = if self.options.get(last).is_some_and(|o| o.disabled) { + next_enabled_option(&self.options, last, -1) + } else { + last + }; + cx.notify(); + } + _ => return, + } + cx.stop_propagation(); + } +} + +impl Render for Select { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); + let label = self.trigger_label(); + let is_placeholder = self.selected.is_none(); + + let mut trigger = div() + .id(self.id.clone()) + .track_focus(&self.focus_handle) + .tab_index(self.tab_index) + // Not `h_flex()`: this row owns its own alignment and the forced + // vertical centring interferes once a detail line is present. + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .h(px(30.)) + .px(px(10.)) + .rounded(px(6.)) + .border_1() + .border_color(colors.border) + .bg(if self.disabled { + colors.element_disabled + } else { + colors.editor_background + }) + .focus_visible({ + let focused = colors.border_focused; + move |style: StyleRefinement| style.border_color(focused) + }) + .on_key_down(cx.listener(Self::on_key_down)) + .child( + div() + .flex_1() + .min_w_0() + .child( + Label::new(label) + .size(LabelSize::Small) + .color(if self.disabled { + Color::Disabled + } else if is_placeholder { + Color::Muted + } else { + Color::Default + }), + ), + ) + .child( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ); + + if self.full_width { + trigger = trigger.w_full(); + } else { + trigger = trigger.min_w(px(180.)); + } + + if !self.disabled { + trigger = trigger + .cursor_pointer() + .hover(|style| style.border_color(colors.border_focused)) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.toggle(cx); + })); + } + + let mut container = div().relative().child(trigger); + if self.full_width { + container = container.w_full(); + } + + if !self.open { + return container; + } + + let mut menu = div() + .id("select-menu") + .flex() + .flex_col() + .min_w(px(200.)) + .max_h(px(280.)) + .overflow_y_scroll() + .py(px(4.)) + .rounded(px(6.)) + .border_1() + .border_color(colors.border) + .bg(colors.elevated_surface_background) + .elevation_2(cx); + + for (index, option) in self.options.iter().enumerate() { + let is_selected = self.selected.as_ref() == Some(&option.id); + let is_highlighted = index == self.highlighted; + let disabled = option.disabled; + + let mut row = div() + .id(ElementId::NamedInteger( + "select-option".into(), + index as u64, + )) + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .mx(px(4.)) + .px(px(8.)) + .py(px(4.)) + .rounded(px(4.)) + .when(is_highlighted && !disabled, |el| { + el.bg(colors.element_selected) + }) + .when_some(option.icon, |el, icon| { + el.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) + }) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + Label::new(option.label.clone()) + .size(LabelSize::Small) + .color(if disabled { + Color::Disabled + } else { + Color::Default + }), + ) + .when_some(option.detail.clone(), |el, detail| { + el.child( + Label::new(detail) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }), + ) + .when(is_selected, |el| { + el.child( + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(Color::Accent), + ) + }); + + if !disabled { + row = row + .cursor_pointer() + .hover(|style| style.bg(colors.ghost_element_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.commit(index, cx); + })); + } + + menu = menu.child(row); + } + + container.child(deferred( + anchored() + .snap_to_window_with_margin(px(8.)) + .child(div().absolute().top(px(34.)).child(menu)), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(specs: &[(&str, bool)]) -> Vec { + specs + .iter() + .map(|(id, disabled)| SelectOption::new(*id, *id).disabled(*disabled)) + .collect() + } + + fn nav(specs: &[(&str, bool)], from: usize, delta: isize) -> usize { + next_enabled_option(&options(specs), from, delta) + } + + #[test] + fn arrow_navigation_moves_one_row_at_a_time() { + let specs = [("a", false), ("b", false), ("c", false)]; + assert_eq!(nav(&specs, 0, 1), 1); + assert_eq!(nav(&specs, 1, 1), 2); + assert_eq!(nav(&specs, 2, -1), 1); + } + + #[test] + fn navigation_clamps_at_both_ends_rather_than_wrapping() { + let specs = [("a", false), ("b", false)]; + assert_eq!(nav(&specs, 1, 1), 1); + assert_eq!(nav(&specs, 0, -1), 0); + } + + /// Enter must never commit a disabled row, so the highlight skips them. + #[test] + fn navigation_skips_disabled_rows() { + let specs = [("a", false), ("b", true), ("c", false)]; + assert_eq!(nav(&specs, 0, 1), 2); + assert_eq!(nav(&specs, 2, -1), 0); + } + + #[test] + fn navigation_on_an_empty_list_stays_put_instead_of_panicking() { + assert_eq!(nav(&[], 0, 1), 0); + assert_eq!(nav(&[], 0, -1), 0); + } + + #[test] + fn a_run_of_disabled_rows_at_the_edge_leaves_the_highlight_where_it_was() { + let specs = [("a", false), ("b", true), ("c", true)]; + assert_eq!(nav(&specs, 0, 1), 0); + } + + #[test] + fn option_builders_compose() { + let option = SelectOption::new("id", "Label") + .detail("1M ctx") + .icon(IconName::Sparkle) + .disabled(true); + assert_eq!(option.id, "id"); + assert_eq!(option.detail.as_deref(), Some("1M ctx")); + assert_eq!(option.icon, Some(IconName::Sparkle)); + assert!(option.disabled); + } +} diff --git a/crates/rgitui_ui/src/status_pill.rs b/crates/rgitui_ui/src/status_pill.rs new file mode 100644 index 00000000..fa815877 --- /dev/null +++ b/crates/rgitui_ui/src/status_pill.rs @@ -0,0 +1,207 @@ +use gpui::prelude::*; +use gpui::{div, px, App, ClickEvent, ElementId, FontWeight, SharedString, Window}; +use rgitui_theme::Color; + +use crate::{ + Button, ButtonSize, ButtonStyle, ClickHandler, Icon, IconName, IconSize, Label, LabelSize, +}; + +/// Whether a credential has been configured and, if so, whether it works. +/// +/// Nothing in the app previously distinguished "a key is present" from "a key +/// that works" — `has_api_key` was `!trim().is_empty()`, so a typo looked +/// identical to a working key until the user staged, clicked, waited, and got +/// a red toast. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + Unconfigured, + Testing, + Connected, + Failed, +} + +impl ConnectionState { + pub fn icon(self) -> IconName { + match self { + ConnectionState::Unconfigured => IconName::DotOutline, + ConnectionState::Testing => IconName::HalfCircle, + ConnectionState::Connected => IconName::CheckCircle, + ConnectionState::Failed => IconName::XCircle, + } + } + + pub fn color(self) -> Color { + match self { + ConnectionState::Unconfigured => Color::Muted, + ConnectionState::Testing => Color::Accent, + ConnectionState::Connected => Color::Success, + ConnectionState::Failed => Color::Error, + } + } + + /// The default label, used when the caller does not supply its own. + pub fn default_label(self) -> &'static str { + match self { + ConnectionState::Unconfigured => "No key", + ConnectionState::Testing => "Testing", + ConnectionState::Connected => "Connected", + ConnectionState::Failed => "Failed", + } + } +} + +/// A compact connection-status indicator: an icon, a label, an optional +/// second line of detail, and an optional action. +/// +/// The icon differs per state as well as the color, so the status is legible +/// without relying on color perception. +#[derive(IntoElement)] +pub struct StatusPill { + id: ElementId, + state: ConnectionState, + label: SharedString, + detail: Option, + action: Option<(SharedString, ClickHandler)>, +} + +impl StatusPill { + pub fn new( + id: impl Into, + state: ConnectionState, + label: impl Into, + ) -> Self { + Self { + id: id.into(), + state, + label: label.into(), + detail: None, + action: None, + } + } + + /// Build a pill with the state's own wording. + pub fn for_state(id: impl Into, state: ConnectionState) -> Self { + Self::new(id, state, state.default_label()) + } + + pub fn detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + pub fn action( + mut self, + label: impl Into, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.action = Some((label.into(), Box::new(on_click))); + self + } +} + +impl RenderOnce for StatusPill { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let color = self.state.color(); + let has_detail = self.detail.is_some(); + + div() + .id(self.id) + .flex() + .flex_row() + .gap(px(6.)) + // Top-aligned rather than centred: once the detail line wraps, + // centring would drift the icon away from the label it belongs to. + .items_start() + .child( + div() + .flex_shrink_0() + // Nudge the icon onto the label's baseline. + .pt(px(1.)) + .child( + Icon::new(self.state.icon()) + .size(IconSize::Small) + .color(color), + ), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(1.)) + .child( + Label::new(self.label) + .size(LabelSize::Small) + .weight(FontWeight::MEDIUM) + .color(color), + ) + .when_some(self.detail, |this, detail| { + this.child( + Label::new(detail) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }), + ) + .when_some(self.action, |this, (label, on_click)| { + this.child( + div().flex_shrink_0().child( + Button::new("status-pill-action", label) + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .color(color) + .on_click(on_click), + ), + ) + }) + .when(has_detail, |this| this.min_h(px(30.))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_state_has_a_distinct_icon_so_it_reads_without_colour() { + let states = [ + ConnectionState::Unconfigured, + ConnectionState::Testing, + ConnectionState::Connected, + ConnectionState::Failed, + ]; + let mut icons: Vec = states.iter().map(|state| state.icon()).collect(); + icons.sort_by_key(|icon| format!("{icon:?}")); + icons.dedup(); + assert_eq!(icons.len(), states.len()); + } + + #[test] + fn every_state_has_a_distinct_colour_and_label() { + let states = [ + ConnectionState::Unconfigured, + ConnectionState::Testing, + ConnectionState::Connected, + ConnectionState::Failed, + ]; + let mut colors: Vec = states + .iter() + .map(|state| format!("{:?}", state.color())) + .collect(); + colors.sort(); + colors.dedup(); + assert_eq!(colors.len(), states.len()); + + for state in states { + assert!(!state.default_label().is_empty()); + } + } + + #[test] + fn a_failed_connection_is_not_rendered_in_the_success_colour() { + assert_eq!(ConnectionState::Failed.color(), Color::Error); + assert_eq!(ConnectionState::Connected.color(), Color::Success); + assert_eq!(ConnectionState::Unconfigured.color(), Color::Muted); + } +} diff --git a/crates/rgitui_ui/src/text_input.rs b/crates/rgitui_ui/src/text_input.rs index 279e327e..d4598e9b 100644 --- a/crates/rgitui_ui/src/text_input.rs +++ b/crates/rgitui_ui/src/text_input.rs @@ -11,6 +11,9 @@ use std::ops::Range; pub enum TextInputEvent { Changed(String), Submit, + /// The input lost focus. Lets a listener flush a pending edit that was + /// never submitted with Enter, rather than silently discarding it. + Blurred, } impl EventEmitter for TextInput {} @@ -34,6 +37,9 @@ pub struct TextInput { compact: bool, disabled: bool, read_only: bool, + /// Registered on the first render, because `on_focus_out` needs a + /// `Window` and construction has none. Dropping it unsubscribes. + focus_out_subscription: Option, } impl TextInput { @@ -52,6 +58,7 @@ impl TextInput { compact: false, disabled: false, read_only: false, + focus_out_subscription: None, } } @@ -497,6 +504,15 @@ impl Focusable for TextInput { impl Render for TextInput { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.focus_out_subscription.is_none() { + let handle = self.focus_handle.clone(); + self.focus_out_subscription = + Some( + cx.on_focus_out(&handle, window, |_this, _event, _window, cx| { + cx.emit(TextInputEvent::Blurred); + }), + ); + } let colors = cx.colors(); let is_focused = self.focus_handle.is_focused(window); // TODO(audit) QUAL-18: clearing the selection on blur from inside the diff --git a/crates/rgitui_ui/src/toast.rs b/crates/rgitui_ui/src/toast.rs index 343e3779..f443e8cd 100644 --- a/crates/rgitui_ui/src/toast.rs +++ b/crates/rgitui_ui/src/toast.rs @@ -42,6 +42,22 @@ impl ToastLevel { ToastLevel::Info => "Info", } } + + /// How long a toast of this level stays on screen, or `None` when it must + /// wait to be dismissed. + /// + /// A single hardcoded three seconds meant errors vanished before they + /// could be read, and a "Generating..." notice expired mid-operation on a + /// tool-calling generation that runs 30s or more. + pub fn auto_dismiss_after(&self) -> Option { + match self { + ToastLevel::Success | ToastLevel::Info => Some(std::time::Duration::from_secs(3)), + ToastLevel::Warning => Some(std::time::Duration::from_secs(6)), + // Sticky. An error the user never saw is an error they cannot act + // on. + ToastLevel::Error => None, + } + } } /// A compact toast notification pill component. @@ -54,6 +70,7 @@ pub struct Toast { message: SharedString, level: ToastLevel, on_dismiss: Option, + action: Option<(SharedString, crate::ClickHandler)>, } impl Toast { @@ -67,9 +84,23 @@ impl Toast { message: message.into(), level, on_dismiss: None, + action: None, } } + /// Attach a single action, rendered as a button beside the message. + /// + /// This is what lets an error carry `[Open Settings]` or `[Retry]` rather + /// than telling the user what went wrong and leaving them to find the fix. + pub fn action( + mut self, + label: impl Into, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.action = Some((label.into(), Box::new(handler))); + self + } + /// Attach a dismiss handler. When set, the toast renders a close button /// that invokes this handler on click. pub fn on_dismiss( @@ -160,6 +191,15 @@ impl RenderOnce for Toast { ), ), ) + .when_some(self.action, |this, (label, on_click)| { + this.child( + crate::Button::new("toast-action", label) + .style(crate::ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .color(level_color) + .on_click(on_click), + ) + }) .when_some(self.on_dismiss, |this, on_dismiss| { this.child( IconButton::new("toast-dismiss", IconName::X) diff --git a/crates/rgitui_workspace/src/branch_dialog.rs b/crates/rgitui_workspace/src/branch_dialog.rs index 11539d33..b4cb38b3 100644 --- a/crates/rgitui_workspace/src/branch_dialog.rs +++ b/crates/rgitui_workspace/src/branch_dialog.rs @@ -46,6 +46,7 @@ impl BranchDialog { TextInputEvent::Submit => { this.try_create(cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(text) => { this.error_message = if text.is_empty() { None diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index 2c6462be..40b9dfd5 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -36,6 +36,12 @@ pub struct CommandContext { /// True when the commit graph has more than one commit selected, which is /// what the multi-commit operations (squash, for one) need. pub has_multi_commit_selection: bool, + /// True when AI is enabled and the active provider has a key. + /// + /// The AI button checked this all along; Ctrl+G and the palette entry + /// checked only `has_staged`, so turning AI off still let the keyboard + /// fire a full request and spend tokens. + pub ai_ready: bool, } impl CommandContext { @@ -52,9 +58,20 @@ impl CommandContext { in_progress_operation: false, has_github_token: false, has_multi_commit_selection: false, + ai_ready: false, } } + /// Records whether AI generation is currently possible: the feature is on + /// and the active provider holds a key. + /// + /// Kept off [`Self::from_parts`], which maps *repository* state: this comes + /// from settings, not from the repo. + pub fn with_ai_ready(mut self, ready: bool) -> Self { + self.ai_ready = ready; + self + } + /// Records whether the commit graph has more than one commit selected. /// /// Kept off [`Self::from_parts`], which maps *repository* state: this flag @@ -95,6 +112,7 @@ impl CommandContext { ), has_github_token, has_multi_commit_selection: false, + ai_ready: false, } } } @@ -139,6 +157,15 @@ pub(crate) const fn has_staged(ctx: CommandContext) -> bool { ctx.has_staged } +/// Show only when AI generation can actually run: something is staged, the +/// feature is enabled, and the active provider has a key. +/// +/// The single predicate the button, the keybinding and the palette all share, +/// so the keyboard and the mouse cannot disagree about availability. +pub(crate) const fn ai_ready(ctx: CommandContext) -> bool { + ctx.has_staged && ctx.ai_ready +} + /// Show only when in a merge, rebase, cherry-pick, or revert in-progress state. pub(crate) const fn in_progress_operation(ctx: CommandContext) -> bool { ctx.in_progress_operation @@ -459,7 +486,7 @@ pub(crate) fn palette_commands() -> Vec { Some("Use AI to generate a commit message based on staged changes"), "AI", ) - .with_predicate(has_staged), + .with_predicate(ai_ready), PaletteCommand::new( CommandId::Refresh, "Git: Refresh", @@ -642,6 +669,7 @@ impl CommandPalette { TextInputEvent::Submit => { this.select_current(cx); } + TextInputEvent::Blurred => {} }, ) .detach(); @@ -689,35 +717,6 @@ impl CommandPalette { self.context = context; } - /// Fuzzy subsequence match. Returns a score (higher = better) or None if - /// query chars don't all appear in target in order. - pub(crate) fn fuzzy_score(query: &str, target: &str) -> Option { - if query.is_empty() { - return Some(0); - } - let target_len = target.len(); - let mut score: usize = 0; - let mut t_chars = target.char_indices().peekable(); - // query is already lowercased by caller (update_filter); targets are also lowercased by caller. - // We still do case-insensitive for safety in direct calls. - let query_lc = query.to_lowercase(); - for q_char in query_lc.chars() { - loop { - match t_chars.next() { - Some((pos, t_char)) => { - if t_char.to_ascii_lowercase() == q_char { - // Prefer matches at earlier positions → higher score - score += target_len.saturating_sub(pos); - break; - } - } - None => return None, // query char not found - } - } - } - Some(score) - } - fn update_filter(&mut self, cx: &mut Context) { let query = self.query_editor.read(cx).text().to_lowercase(); @@ -744,7 +743,7 @@ impl CommandPalette { let cat_lc = cmd.category.to_lowercase(); let score = [label_lc.as_str(), id_lc.as_str(), cat_lc.as_str()] .iter() - .filter_map(|target| Self::fuzzy_score(&query, target)) + .filter_map(|target| rgitui_ui::fuzzy_score(&query, target)) .max(); score.map(|s| (i, s)) }) @@ -1087,99 +1086,6 @@ impl Render for CommandPalette { #[cfg(test)] mod tests { - use super::CommandPalette; - - #[test] - fn fuzzy_score_exact_match_returns_score() { - assert!(CommandPalette::fuzzy_score("push", "Push to Remote").is_some()); - } - - #[test] - fn fuzzy_score_case_insensitive() { - assert!(CommandPalette::fuzzy_score("push", "PUSH").is_some()); - assert!(CommandPalette::fuzzy_score("PUSH", "push").is_some()); - } - - #[test] - fn fuzzy_score_missing_char_returns_none() { - assert_eq!(CommandPalette::fuzzy_score("xyz", "Push"), None); - } - - #[test] - fn fuzzy_score_empty_query_returns_zero() { - assert_eq!(CommandPalette::fuzzy_score("", "Push to Remote"), Some(0)); - } - - #[test] - fn fuzzy_score_earlier_match_higher_score() { - let score_early = CommandPalette::fuzzy_score("sh", "Show").unwrap(); - let score_late = CommandPalette::fuzzy_score("sh", "Fish").unwrap(); - assert!( - score_early > score_late, - "earlier match should score higher: {score_early} vs {score_late}" - ); - } - - #[test] - fn fuzzy_score_subsequence_in_order() { - assert!(CommandPalette::fuzzy_score("pd", "Push and Delete").is_some()); - assert_eq!(CommandPalette::fuzzy_score("dp", "Push and Delete"), None); - } - - #[test] - fn fuzzy_score_longer_target_scores_higher_when_same_prefix() { - // Same query "co", same positions, longer target gives higher score - // because score = sum(target_len - matched_pos) - let score_short = CommandPalette::fuzzy_score("co", "Commit").unwrap(); - let score_long = CommandPalette::fuzzy_score("co", "Commit Message").unwrap(); - assert!( - score_long > score_short, - "longer matching target should score higher: {score_long} vs {score_short}" - ); - } - - #[test] - fn fuzzy_score_repeated_chars() { - assert_eq!(CommandPalette::fuzzy_score("pp", "Push"), None); - assert!(CommandPalette::fuzzy_score("ps", "Push").is_some()); - } - - #[test] - fn fuzzy_score_single_char_query() { - // Single char should match first occurrence - assert!(CommandPalette::fuzzy_score("a", "Push").is_none()); - assert!(CommandPalette::fuzzy_score("p", "Push").is_some()); - assert!(CommandPalette::fuzzy_score("u", "Push").is_some()); - assert!(CommandPalette::fuzzy_score("s", "Push").is_some()); - } - - #[test] - fn fuzzy_score_query_longer_than_target() { - // Query longer than target: should fail - assert_eq!(CommandPalette::fuzzy_score("pushit", "Push"), None); - } - - #[test] - fn fuzzy_score_empty_target() { - // Non-empty query with empty target should fail - assert_eq!(CommandPalette::fuzzy_score("abc", ""), None); - } - - #[test] - fn fuzzy_score_numbers_and_special_chars() { - // Numbers in query and target - assert!(CommandPalette::fuzzy_score("42", "Answer 42").is_some()); - assert!(CommandPalette::fuzzy_score("v2", "version2").is_some()); - // Special characters - assert!(CommandPalette::fuzzy_score("rmrf", "rm -rf").is_some()); - } - - #[test] - fn fuzzy_score_unicode() { - // Unicode characters - assert!(CommandPalette::fuzzy_score("caf", "Café").is_some()); - assert!(CommandPalette::fuzzy_score("日本語", "日本語テスト").is_some()); - } #[test] fn command_id_stash_branch() { diff --git a/crates/rgitui_workspace/src/commit_panel.rs b/crates/rgitui_workspace/src/commit_panel.rs index 173e9613..f892aa58 100644 --- a/crates/rgitui_workspace/src/commit_panel.rs +++ b/crates/rgitui_workspace/src/commit_panel.rs @@ -3,6 +3,7 @@ use gpui::{ div, px, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; +use rgitui_ai::CommitStyle; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonSize, ButtonStyle, CheckState, Checkbox, IconButton, IconName, Label, LabelSize, @@ -34,17 +35,116 @@ impl CoAuthor { #[derive(Debug, Clone)] pub enum CommitPanelEvent { - CommitRequested { message: String, amend: bool }, + CommitRequested { + message: String, + amend: bool, + }, GenerateAiMessage, + /// Regenerate, optionally overriding the configured commit style for this + /// one request. Belongs at the moment of dissatisfaction, not in a + /// settings page in another window. + RegenerateAiMessage { + style: Option, + }, + CancelAiMessage, + /// Nothing is configured for AI yet, and the user asked to fix that. + OpenAiSettings, CollapsedChanged, } +/// Why the AI button cannot be used right now, or `None` when it can. +/// +/// Every entry point (button, Ctrl+G, command palette) resolves through this +/// one predicate, so the keyboard and the mouse cannot disagree about whether +/// the feature is available. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AiBlocker { + Disabled, + NoApiKey, + NothingStaged, +} + +impl AiBlocker { + /// The button's own label. The reason belongs in the control, not only in + /// a hover tooltip on something that reads as inert. + pub fn button_label(self) -> &'static str { + match self { + AiBlocker::Disabled => "AI is off", + AiBlocker::NoApiKey => "Add an API key", + AiBlocker::NothingStaged => "Stage files to use AI", + } + } + + pub fn tooltip(self) -> &'static str { + match self { + AiBlocker::Disabled => "AI is turned off — enable it in Settings > AI", + AiBlocker::NoApiKey => "No API key for the selected provider. Opens Settings > AI.", + AiBlocker::NothingStaged => "Stage changes first to generate an AI message", + } + } + + /// Whether the button stays clickable. + /// + /// Never render a dead end when the fix is one click away: a missing key + /// and a disabled feature both route to Settings. Only "nothing staged" is + /// truly inert, and it is self-correcting — the user is about to stage. + pub fn is_actionable(self) -> bool { + matches!(self, AiBlocker::Disabled | AiBlocker::NoApiKey) + } +} + +/// Split a commit message into its summary line and body. +pub(crate) fn split_message(message: &str) -> (String, String) { + match message.find('\n') { + Some(index) => ( + message[..index].trim().to_string(), + message[index + 1..].trim().to_string(), + ), + None => (message.trim().to_string(), String::new()), + } +} + +/// Resolve whether AI generation can run. Pure, so all three entry points can +/// share it and it is testable without a display. +pub fn ai_blocker(enabled: bool, has_api_key: bool, staged_count: usize) -> Option { + if !enabled { + return Some(AiBlocker::Disabled); + } + if !has_api_key { + return Some(AiBlocker::NoApiKey); + } + if staged_count == 0 { + return Some(AiBlocker::NothingStaged); + } + None +} + +/// What the panel is doing about AI right now. +#[derive(Debug, Clone, PartialEq, Eq)] +enum AiState { + Idle, + /// Running, with the latest tool-progress line if there is one. + Generating { + progress: Option, + }, + /// Just finished. Offers Regenerate and Undo for a short window. + Completed, + /// Failed, and the panel keeps saying so — a dismissed toast leaves no + /// trace of why the field is still empty. + Failed, +} + pub struct CommitPanel { summary_editor: Entity, description_editor: Entity, amend: bool, staged_count: usize, - is_ai_generating: bool, + ai_state: AiState, + /// The message the AI replaced, so the overwrite can be undone. + /// `UndoStack` covers git operations, not the commit editor. + ai_undo: Option, + /// Open state of the regenerate style menu. + regenerate_menu_open: bool, focus_handle: FocusHandle, co_authors: Vec, adding_co_author: bool, @@ -53,6 +153,22 @@ pub struct CommitPanel { collapsed: bool, } +/// A snapshot of the commit editors, taken before the AI overwrites them. +#[derive(Debug, Clone, Default)] +struct PreviousMessage { + summary: String, + description: String, + co_authors: Vec, +} + +impl PreviousMessage { + fn is_empty(&self) -> bool { + self.summary.trim().is_empty() + && self.description.trim().is_empty() + && self.co_authors.is_empty() + } +} + impl EventEmitter for CommitPanel {} impl CommitPanel { @@ -100,7 +216,9 @@ impl CommitPanel { description_editor, amend: false, staged_count: 0, - is_ai_generating: false, + ai_state: AiState::Idle, + ai_undo: None, + regenerate_menu_open: false, focus_handle: cx.focus_handle(), co_authors: Vec::new(), adding_co_author: false, @@ -121,13 +239,7 @@ impl CommitPanel { } pub fn set_message(&mut self, message: String, cx: &mut Context) { - let (summary, description) = match message.find('\n') { - Some(idx) => ( - message[..idx].trim().to_string(), - message[idx + 1..].trim().to_string(), - ), - None => (message, String::new()), - }; + let (summary, description) = split_message(&message); self.summary_editor .update(cx, |e: &mut TextInput, cx| e.set_text(summary, cx)); self.description_editor @@ -185,16 +297,106 @@ impl CommitPanel { msg } + pub fn staged_count(&self) -> usize { + self.staged_count + } + pub fn set_staged_count(&mut self, count: usize, cx: &mut Context) { self.staged_count = count; cx.notify(); } - pub fn set_ai_generating(&mut self, generating: bool, cx: &mut Context) { - self.is_ai_generating = generating; + pub fn is_ai_generating(&self) -> bool { + matches!(self.ai_state, AiState::Generating { .. }) + } + + /// A generation has started for this panel. + /// + /// The editors go read-only for the duration, so the user cannot type a + /// message that the response is about to silently destroy. + pub fn begin_ai_generation(&mut self, cx: &mut Context) { + self.ai_state = AiState::Generating { progress: None }; + self.regenerate_menu_open = false; + self.set_editors_read_only(true, cx); cx.notify(); } + /// Report what the model is doing right now ("Reading diff.rs"). + pub fn set_ai_progress(&mut self, progress: Option, cx: &mut Context) { + if let AiState::Generating { progress: slot } = &mut self.ai_state { + *slot = progress; + cx.notify(); + } + } + + /// Apply a generated message, preserving anything the user had already + /// written. + /// + /// The old behaviour overwrote both editors and cleared every co-author + /// with no undo, so typing while waiting lost the work outright. + pub fn apply_ai_message(&mut self, message: String, cx: &mut Context) { + let previous = self.snapshot(cx); + self.set_editors_read_only(false, cx); + + let (summary, description) = split_message(&message); + self.summary_editor + .update(cx, |e: &mut TextInput, cx| e.set_text(summary, cx)); + self.description_editor + .update(cx, |e: &mut TextInput, cx| e.set_text(description, cx)); + // Co-authors are the user's own attribution, never the model's to + // remove. + self.adding_co_author = false; + + self.ai_undo = (!previous.is_empty()).then_some(previous); + self.ai_state = AiState::Completed; + cx.notify(); + } + + /// The generation failed or was cancelled. + pub fn fail_ai_generation(&mut self, cx: &mut Context) { + self.set_editors_read_only(false, cx); + self.ai_state = AiState::Failed; + cx.notify(); + } + + /// Restore the message the AI replaced. + pub fn undo_ai_message(&mut self, cx: &mut Context) { + let Some(previous) = self.ai_undo.take() else { + return; + }; + self.summary_editor + .update(cx, |e: &mut TextInput, cx| e.set_text(previous.summary, cx)); + self.description_editor.update(cx, |e: &mut TextInput, cx| { + e.set_text(previous.description, cx) + }); + self.co_authors = previous.co_authors; + self.ai_state = AiState::Idle; + cx.notify(); + } + + /// Dismiss the post-generation controls without changing the message. + pub fn dismiss_ai_state(&mut self, cx: &mut Context) { + self.ai_state = AiState::Idle; + self.ai_undo = None; + self.regenerate_menu_open = false; + cx.notify(); + } + + fn snapshot(&self, cx: &Context) -> PreviousMessage { + PreviousMessage { + summary: self.summary_editor.read(cx).text().to_string(), + description: self.description_editor.read(cx).text().to_string(), + co_authors: self.co_authors.clone(), + } + } + + fn set_editors_read_only(&self, read_only: bool, cx: &mut Context) { + self.summary_editor + .update(cx, |e: &mut TextInput, _cx| e.set_read_only(read_only)); + self.description_editor + .update(cx, |e: &mut TextInput, _cx| e.set_read_only(read_only)); + } + pub fn focus(&self, window: &mut Window, cx: &mut Context) { self.summary_editor .update(cx, |e: &mut TextInput, cx| e.focus(window, cx)); @@ -216,6 +418,256 @@ impl CommitPanel { } } + /// The AI control in the panel header. + /// + /// Four states, and none of them is "absent": a vanishing control is + /// worse than a disabled one, and a user who turned AI off previously saw + /// no AI affordance at all and no route back. + fn render_ai_control( + &self, + blocker: Option, + use_tools: bool, + cx: &mut Context, + ) -> gpui::AnyElement { + let colors = cx.colors().clone(); + + match &self.ai_state { + AiState::Generating { progress } => { + // The tool progress line goes here, not only to the status + // bar: "Reading diff.rs" turns an opaque spinner into a + // legible trace of what the model is actually doing. + let label: SharedString = match progress { + Some(description) => description.clone().into(), + None if use_tools => "Generating (with tools)…".into(), + None => "Generating…".into(), + }; + div() + .h_flex() + .flex_shrink_0() + .h(px(22.)) + .pl(px(8.)) + .pr(px(2.)) + .rounded(px(3.)) + .bg(colors.ghost_element_selected) + .items_center() + .gap(px(4.)) + .child( + rgitui_ui::Icon::new(IconName::Sparkle) + .size(rgitui_ui::IconSize::XSmall) + .color(Color::Accent), + ) + .child( + Label::new(label) + .size(LabelSize::XSmall) + .color(Color::Accent), + ) + // Required, not decorative: with tools on a generation + // runs 30s or more, and there was previously no way to + // stop one short of restarting the app. + .child( + IconButton::new("ai-cancel", IconName::X) + .size(ButtonSize::Compact) + .color(Color::Muted) + .tooltip("Stop generating") + .on_click(cx.listener(|_this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + cx.emit(CommitPanelEvent::CancelAiMessage); + })), + ) + .into_any_element() + } + + AiState::Completed => self.render_post_generation_controls(cx), + + AiState::Failed => div() + .h_flex() + .flex_shrink_0() + .gap(px(4.)) + .items_center() + .child( + // A dismissed toast leaves no trace of why the field is + // still empty, so the panel keeps the marker. + rgitui_ui::Icon::new(IconName::AlertTriangle) + .size(rgitui_ui::IconSize::XSmall) + .color(Color::Error), + ) + .child( + Button::new("ai-retry", "AI failed — retry") + .icon(IconName::Refresh) + .size(ButtonSize::Compact) + .style(ButtonStyle::Outlined) + .color(Color::Error) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.ai_state = AiState::Idle; + cx.emit(CommitPanelEvent::GenerateAiMessage); + })), + ) + .into_any_element(), + + AiState::Idle => self.render_ai_trigger(blocker, cx), + } + } + + /// The idle AI button, carrying its own reason when it cannot be used. + fn render_ai_trigger( + &self, + blocker: Option, + cx: &mut Context, + ) -> gpui::AnyElement { + let summary_present = !self.summary_editor.read(cx).is_empty(); + let label = match blocker { + Some(blocker) => blocker.button_label(), + // Say plainly that this will replace what is already written. + None if summary_present => "Rewrite message", + None => "AI Message", + }; + + let mut button = Button::new("ai-btn", label) + .icon(IconName::Sparkle) + .size(ButtonSize::Compact) + .style(ButtonStyle::Outlined) + .color(Color::Accent) + // Disabled buttons are dropped from the tab order, so anything + // that stays disabled becomes unreachable without a mouse. Only + // the self-correcting case does. + .disabled(blocker.is_some_and(|blocker| !blocker.is_actionable())); + + button = match blocker { + Some(blocker) => button.tooltip(blocker.tooltip()), + // Surface the shortcut. It was registered all along and never + // shown, so the fastest path to the feature was invisible. + None => button.tooltip_fn(crate::keymap::command_tooltip( + "Generate a commit message from the staged diff", + crate::CommandId::AiMessage, + )), + }; + + button + .on_click(cx.listener(move |_this, _: &ClickEvent, _, cx| { + match blocker { + // The fix is one click away; take the user to it rather + // than rendering a dead end. + Some(AiBlocker::Disabled) | Some(AiBlocker::NoApiKey) => { + cx.emit(CommitPanelEvent::OpenAiSettings) + } + Some(AiBlocker::NothingStaged) => {} + None => cx.emit(CommitPanelEvent::GenerateAiMessage), + } + })) + .into_any_element() + } + + /// Regenerate (with an optional style override) and Undo, offered right + /// where dissatisfaction happens rather than in a settings page in another + /// window. + fn render_post_generation_controls(&self, cx: &mut Context) -> gpui::AnyElement { + let colors = cx.colors().clone(); + let mut row = div() + .h_flex() + .flex_shrink_0() + .gap(px(4.)) + .items_center() + .child( + Button::new("ai-regenerate", "Regenerate") + .icon(IconName::Refresh) + .size(ButtonSize::Compact) + .style(ButtonStyle::Outlined) + .color(Color::Accent) + .on_click(cx.listener(|_this, _: &ClickEvent, _, cx| { + cx.emit(CommitPanelEvent::RegenerateAiMessage { style: None }); + })), + ) + .child( + IconButton::new("ai-regenerate-menu", IconName::ChevronDown) + .size(ButtonSize::Compact) + .color(Color::Muted) + .tooltip("Regenerate in a different style") + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.regenerate_menu_open = !this.regenerate_menu_open; + cx.notify(); + })), + ); + + if self.ai_undo.is_some() { + row = row.child( + Button::new("ai-undo", "Undo") + .icon(IconName::Undo) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .color(Color::Muted) + .tooltip("Restore the message the AI replaced") + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.undo_ai_message(cx); + })), + ); + } + + row = row.child( + IconButton::new("ai-dismiss", IconName::X) + .size(ButtonSize::Compact) + .color(Color::Muted) + .tooltip("Dismiss") + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.dismiss_ai_state(cx); + })), + ); + + if !self.regenerate_menu_open { + return row.into_any_element(); + } + + let mut menu = div() + .flex() + .flex_col() + .min_w(px(150.)) + .py(px(4.)) + .rounded(px(6.)) + .border_1() + .border_color(colors.border) + .bg(colors.elevated_surface_background) + .elevation_2(cx); + + for style in CommitStyle::ALL { + let style = *style; + menu = menu.child( + div() + .id(ElementId::Name(format!("ai-style-{}", style.id()).into())) + .flex() + .flex_row() + .items_center() + .h(px(26.)) + .mx(px(4.)) + .px(px(8.)) + .rounded(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(colors.ghost_element_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.regenerate_menu_open = false; + cx.emit(CommitPanelEvent::RegenerateAiMessage { style: Some(style) }); + })) + .child( + Label::new(style.display_name()) + .size(LabelSize::XSmall) + .color(Color::Default), + ), + ); + } + + div() + .relative() + .child(row) + .child(gpui::deferred( + gpui::anchored() + .snap_to_window_with_margin(px(8.)) + .child(div().absolute().top(px(26.)).right(px(0.)).child(menu)), + )) + .into_any_element() + } + fn start_adding_co_author(&mut self, cx: &mut Context) { self.adding_co_author = true; self.new_author_name @@ -250,7 +702,7 @@ impl CommitPanel { impl Render for CommitPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); let summary_empty = self.summary_editor.read(cx).is_empty(); let can_commit = !summary_empty && self.staged_count > 0; let summary_len = self.summary_editor.read(cx).text().chars().count(); @@ -298,18 +750,28 @@ impl Render for CommitPanel { let commit_label = self.commit_button_label(summary_empty); + // `has_ai_api_key()` reads the cached flag. The old + // `ai_api_key().is_some()` deep-cloned the AI key, the HTTPS token and + // every git provider token into fresh heap strings on every frame — + // ~120 copies a second of every secret the app holds, dropped without + // zeroization — purely to evaluate `.is_some()`. let ai_settings = cx .try_global::() .map(|s| { let settings = s.settings(); ( settings.ai.enabled, - s.ai_api_key().is_some(), + s.has_ai_api_key(), settings.ai.use_tools, ) }) .unwrap_or((false, false, false)); let (ai_enabled, has_api_key, ai_use_tools) = ai_settings; + let blocker = ai_blocker(ai_enabled, has_api_key, self.staged_count); + // Built before the tree so it can take `&mut Context` without + // conflicting with the immutable borrows the layout holds. + let ai_control = + (!self.collapsed).then(|| self.render_ai_control(blocker, ai_use_tools, cx)); div() .v_flex() @@ -385,55 +847,9 @@ impl Render for CommitPanel { )), ), ) - // Right group: AI button or generating indicator - .when(!self.collapsed && self.is_ai_generating, |el| { - el.child( - div() - .h_flex() - .flex_shrink_0() - .h(px(20.)) - .px(px(8.)) - .rounded(px(3.)) - .bg(colors.ghost_element_selected) - .items_center() - .gap(px(4.)) - .child( - rgitui_ui::Icon::new(IconName::Sparkle) - .size(rgitui_ui::IconSize::XSmall) - .color(Color::Accent), - ) - .child( - Label::new(if ai_use_tools { - "Generating (with tools)..." - } else { - "Generating..." - }) - .size(LabelSize::XSmall) - .color(Color::Accent), - ), - ) - }) - .when( - !self.collapsed && !self.is_ai_generating && ai_enabled, - |el| { - let no_staged = self.staged_count == 0; - let is_disabled = no_staged || !has_api_key; - let mut btn = Button::new("ai-btn", "AI Message") - .icon(IconName::Sparkle) - .size(ButtonSize::Compact) - .style(ButtonStyle::Outlined) - .color(Color::Accent) - .disabled(is_disabled); - if !has_api_key { - btn = btn.tooltip("Set an API key in Settings to use AI"); - } else if no_staged { - btn = btn.tooltip("Stage changes first to generate an AI message"); - } - el.child(btn.on_click(cx.listener(|_this, _: &ClickEvent, _, cx| { - cx.emit(CommitPanelEvent::GenerateAiMessage); - }))) - }, - ), + // Right group: the AI control, in whichever of its four + // states applies. + .when_some(ai_control, |el, control| el.child(control)), ) .child( div() @@ -821,4 +1237,113 @@ mod tests { assert!(trailer.contains("Jane Doe")); assert!(trailer.contains("")); } + + // ── the shared AI guard ─────────────────────────────────────── + + /// The one predicate the button, Ctrl+G and the command palette all share. + /// Only the button used to check `enabled` and `has_api_key`, so Ctrl+G + /// with AI turned off still fired a full request and spent tokens. + #[test] + fn generation_is_allowed_only_when_everything_is_in_place() { + assert_eq!(ai_blocker(true, true, 3), None); + } + + #[test] + fn each_missing_precondition_is_reported_in_priority_order() { + // Disabled outranks everything: with AI off, "add a key" would be the + // wrong instruction. + assert_eq!(ai_blocker(false, false, 0), Some(AiBlocker::Disabled)); + assert_eq!(ai_blocker(false, true, 3), Some(AiBlocker::Disabled)); + assert_eq!(ai_blocker(true, false, 3), Some(AiBlocker::NoApiKey)); + assert_eq!(ai_blocker(true, true, 0), Some(AiBlocker::NothingStaged)); + } + + /// Never render a dead end when the fix is one click away — and disabled + /// buttons drop out of the tab order, so anything left disabled becomes + /// unreachable without a mouse. + #[test] + fn only_the_self_correcting_blocker_actually_disables_the_button() { + assert!(AiBlocker::Disabled.is_actionable()); + assert!(AiBlocker::NoApiKey.is_actionable()); + assert!(!AiBlocker::NothingStaged.is_actionable()); + } + + #[test] + fn every_blocker_states_its_reason_in_the_control_and_the_tooltip() { + for blocker in [ + AiBlocker::Disabled, + AiBlocker::NoApiKey, + AiBlocker::NothingStaged, + ] { + assert!(!blocker.button_label().is_empty()); + assert!(!blocker.tooltip().is_empty()); + assert_ne!(blocker.button_label(), blocker.tooltip()); + } + } + + // ── message splitting ───────────────────────────────────────── + + #[test] + fn a_single_line_message_is_all_summary() { + assert_eq!( + split_message("feat: do the thing"), + ("feat: do the thing".to_string(), String::new()) + ); + } + + #[test] + fn a_body_is_separated_from_the_summary_and_trimmed() { + assert_eq!( + split_message( + "feat: do it + +Because reasons. +" + ), + ("feat: do it".to_string(), "Because reasons.".to_string()) + ); + } + + #[test] + fn an_empty_message_splits_into_two_empty_halves() { + assert_eq!(split_message(""), (String::new(), String::new())); + assert_eq!(split_message(" "), (String::new(), String::new())); + } + + // ── the AI overwrite snapshot ───────────────────────────────── + + /// Typing while a generation ran used to lose the work outright: both + /// editors were overwritten and every co-author cleared, with no undo. + #[test] + fn a_snapshot_with_any_content_is_worth_restoring() { + let empty = PreviousMessage::default(); + assert!(empty.is_empty()); + + let with_summary = PreviousMessage { + summary: "wip".into(), + ..PreviousMessage::default() + }; + assert!(!with_summary.is_empty()); + + let with_co_author = PreviousMessage { + co_authors: vec![CoAuthor { + name: "Jane Doe".into(), + email: "jane@example.org".into(), + }], + ..PreviousMessage::default() + }; + assert!(!with_co_author.is_empty()); + } + + #[test] + fn whitespace_alone_is_not_worth_restoring() { + let blank = PreviousMessage { + summary: " ".into(), + description: " +" + .into(), + ..PreviousMessage::default() + }; + assert!(blank.is_empty()); + } } diff --git a/crates/rgitui_workspace/src/detail_panel.rs b/crates/rgitui_workspace/src/detail_panel.rs index 54396bcf..c823a8e5 100644 --- a/crates/rgitui_workspace/src/detail_panel.rs +++ b/crates/rgitui_workspace/src/detail_panel.rs @@ -791,8 +791,7 @@ impl DetailPanel { .enumerate() .filter_map(|(i, file)| { let path = file.path.to_string_lossy(); - crate::command_palette::CommandPalette::fuzzy_score(query, &path) - .map(|score| (score, i)) + rgitui_ui::fuzzy_score(query, &path).map(|score| (score, i)) }) .collect(); // Sort by score descending — higher score = better (earlier char match) @@ -2627,10 +2626,9 @@ mod tests { #[test] fn test_filtered_files_relevance_order() { - use crate::command_palette::CommandPalette; // "sh" matches "Show" (pos 0) higher than "Fish" (pos 1) - let score_show = CommandPalette::fuzzy_score("sh", "Show").unwrap(); - let score_fish = CommandPalette::fuzzy_score("sh", "Fish").unwrap(); + let score_show = rgitui_ui::fuzzy_score("sh", "Show").unwrap(); + let score_fish = rgitui_ui::fuzzy_score("sh", "Fish").unwrap(); assert!( score_show > score_fish, "earlier match should score higher: {score_show} vs {score_fish}" diff --git a/crates/rgitui_workspace/src/issues_panel.rs b/crates/rgitui_workspace/src/issues_panel.rs index b73261d2..5c681c73 100644 --- a/crates/rgitui_workspace/src/issues_panel.rs +++ b/crates/rgitui_workspace/src/issues_panel.rs @@ -213,6 +213,7 @@ impl IssuesPanel { let query = input.read(cx).text().to_string(); this.submit_search(query, cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(_) => {} }, ) diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index 294f4422..5cd920e4 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -31,7 +31,7 @@ // invocation site; `always_show` is the default and is referenced through // `$crate`, so it is not imported here. use crate::command_palette::{ - has_changes, has_github_token, has_multi_commit_selection, has_remotes, has_staged, + ai_ready, has_changes, has_github_token, has_multi_commit_selection, has_remotes, has_staged, has_stashes, in_progress_operation, is_bisecting, worktree_clean, CommandContext, }; @@ -130,7 +130,7 @@ commands! { /// Search the commit graph. Search ["secondary-f", "/" in "Workspace && !modal && !TextInput"]; /// Generate a commit message with the configured AI provider. - AiMessage "secondary-g" if has_staged; + AiMessage "secondary-g" if ai_ready; /// Reload the repository state from disk. Refresh "f5"; /// Open the settings window. diff --git a/crates/rgitui_workspace/src/rename_dialog.rs b/crates/rgitui_workspace/src/rename_dialog.rs index 157ddd58..c3e7fd68 100644 --- a/crates/rgitui_workspace/src/rename_dialog.rs +++ b/crates/rgitui_workspace/src/rename_dialog.rs @@ -46,6 +46,7 @@ impl RenameDialog { TextInputEvent::Submit => { this.try_rename(cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(text) => { this.error_message = if text.is_empty() { None diff --git a/crates/rgitui_workspace/src/repo_opener.rs b/crates/rgitui_workspace/src/repo_opener.rs index 44d05cc7..1407a91c 100644 --- a/crates/rgitui_workspace/src/repo_opener.rs +++ b/crates/rgitui_workspace/src/repo_opener.rs @@ -44,6 +44,7 @@ impl RepoOpener { TextInputEvent::Submit => { this.try_open(cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(_) => { this.update_filter(cx); cx.notify(); diff --git a/crates/rgitui_workspace/src/search_panel.rs b/crates/rgitui_workspace/src/search_panel.rs index 4586199f..2c6774d6 100644 --- a/crates/rgitui_workspace/src/search_panel.rs +++ b/crates/rgitui_workspace/src/search_panel.rs @@ -138,6 +138,7 @@ impl GlobalSearchView { } cx.stop_propagation(); } + TextInputEvent::Blurred => {} }, ) .detach(); diff --git a/crates/rgitui_workspace/src/settings_window/ai_section.rs b/crates/rgitui_workspace/src/settings_window/ai_section.rs new file mode 100644 index 00000000..fa3c122c --- /dev/null +++ b/crates/rgitui_workspace/src/settings_window/ai_section.rs @@ -0,0 +1,1492 @@ +//! The AI settings page. +//! +//! Three sections replacing six flat cards, plus a status strip in the header +//! block that never scrolls away: +//! +//! 1. **Connection** — one expandable row per provider, each owning its own +//! key field, its own connection status, and its own model pin. A single +//! shared key field is what made "connected" an assertion the app could not +//! back up. +//! 2. **Model** — folded into the expanded provider row, so it is always shown +//! against the credentials it will actually be used with. +//! 3. **Behaviour** — commit style with a live example, and the two toggles +//! that cost money, each stating what it costs. + +use gpui::prelude::*; +use gpui::{div, px, ClickEvent, Context, ElementId, FontWeight, SharedString}; +use rgitui_ai::catalog::{ + self, classify_pinned, filter_models, CatalogSource, ModelFilter, ModelInfo, PinnedModelStatus, +}; +use rgitui_ai::CommitStyle; +use rgitui_settings::{AiProvider, SettingsState}; +use rgitui_theme::{ActiveTheme, Color}; +use rgitui_ui::{ + Button, ButtonSize, ButtonStyle, CheckState, Checkbox, ConnectionState, Disclosure, Icon, + IconButton, IconName, IconSize, Label, LabelSize, PickerChip, PickerRow, StatusPill, +}; + +use super::view::{ + credential_store_name, masked_tail, relative_age, MaskedField, SettingsSection, SettingsView, + SETTINGS_TAB_INDEX_BASE, +}; + +/// How long a connection test may take before it is treated as a failure. +const CONNECTION_TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +/// How long to wait after the last keystroke before writing a secret. +const SECRET_SAVE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(400); + +impl SettingsView { + // ── Behaviour ──────────────────────────────────────────────────── + + /// Show the AI page. Everything that reports an AI misconfiguration routes + /// here so the complaint and its fix are one click apart. + pub fn show_ai_section(&mut self, cx: &mut Context) { + self.active_section = SettingsSection::Ai; + let provider = self.ai_provider; + self.expanded_ai_provider = Some(provider); + self.load_ai_catalog(provider, cx); + cx.notify(); + } + + /// Queue a keychain write for `SECRET_SAVE_DEBOUNCE` from now. + /// + /// Replaces the old per-keystroke-then-never model, where typing a key and + /// closing the window discarded it while pasting the identical key saved it + /// immediately: same field, same value, three different outcomes. + pub(super) fn schedule_secret_save(&mut self, cx: &mut Context) { + self.pending_secret_save = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + cx.background_executor().timer(SECRET_SAVE_DEBOUNCE).await; + this.update(cx, |this, cx| { + this.pending_secret_save = None; + this.save_settings(cx); + }) + .ok(); + })); + cx.notify(); + } + + /// Write any pending secret immediately, cancelling the debounce. + pub(super) fn flush_secret_save(&mut self, cx: &mut Context) { + self.pending_secret_save = None; + self.save_settings(cx); + } + + /// Make `provider` the active one, remembering the model the previous + /// provider was using so switching back restores it. + pub(super) fn use_ai_provider(&mut self, provider: AiProvider, cx: &mut Context) { + if self.ai_provider == provider { + return; + } + // Remember the outgoing provider's model before overwriting the pin. + let previous = self.ai_provider; + let previous_model = self.ai_model.clone(); + cx.update_global::(|state, _cx| { + let ai = &mut state.settings_mut().ai; + if !previous_model.trim().is_empty() { + ai.models_by_provider + .insert(previous.id().to_string(), previous_model); + } + ai.set_active_provider(provider); + }); + self.ai_provider = provider; + self.ai_model = cx + .read_global::(|state, _cx| state.settings().ai.model_for(provider)); + self.expanded_ai_provider = Some(provider); + self.save_settings(cx); + self.load_ai_catalog(provider, cx); + cx.notify(); + } + + /// Expand one provider row, collapsing whichever was open. + pub(super) fn toggle_ai_provider_row(&mut self, provider: AiProvider, cx: &mut Context) { + if self.expanded_ai_provider == Some(provider) { + self.expanded_ai_provider = None; + } else { + self.expanded_ai_provider = Some(provider); + self.ai_model_picker_open = false; + self.load_ai_catalog(provider, cx); + } + cx.notify(); + } + + pub(super) fn select_ai_model(&mut self, model: String, cx: &mut Context) { + self.ai_model = model.clone(); + self.ai_model_picker_open = false; + cx.update_global::(|state, _cx| { + state.settings_mut().ai.set_active_model(model); + }); + self.save_settings(cx); + cx.notify(); + } + + pub(super) fn commit_base_url_override(&mut self, cx: &mut Context) { + let value = self.ai_base_url_editor.read(cx).text().trim().to_string(); + if let Err(error) = rgitui_ai::validate_base_url(&value) { + self.set_feedback(error.message(), true, cx); + return; + } + self.ai_base_url_override = value; + self.save_settings(cx); + } + + /// The one honest answer the settings page can give about a key. + /// + /// `has_api_key` was only `!trim().is_empty()`, so a typo was + /// indistinguishable from a working key until the user staged, clicked, + /// waited and got a red toast. + pub(super) fn test_ai_connection(&mut self, provider: AiProvider, cx: &mut Context) { + let key = self + .ai_key_editors + .get(&provider) + .map(|editor| editor.read(cx).text().trim().to_string()) + .unwrap_or_default(); + if key.is_empty() { + self.ai_connection + .insert(provider, ConnectionState::Unconfigured); + cx.notify(); + return; + } + + self.ai_connection + .insert(provider, ConnectionState::Testing); + self.ai_connection_error.remove(&provider); + cx.notify(); + + let client = cx.http_client(); + self.ai_test_task = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + // The round-trip and the parse both run off the UI thread. + let executor = cx.background_executor().clone(); + let probe = executor + .spawn(async move { catalog::fetch_models(provider, &client, Some(&key)).await }); + let timeout = cx.background_executor().timer(CONNECTION_TEST_TIMEOUT); + let result = futures::future::select(Box::pin(probe), Box::pin(timeout)).await; + + let outcome = match result { + futures::future::Either::Left((outcome, _)) => outcome, + futures::future::Either::Right(((), _)) => Err(anyhow::anyhow!( + "{} did not respond within {}s.", + provider.display_name(), + CONNECTION_TEST_TIMEOUT.as_secs() + )), + }; + + this.update(cx, |this, cx| match outcome { + Ok(models) => { + this.ai_connection + .insert(provider, ConnectionState::Connected); + this.ai_connection_error.remove(&provider); + this.ai_verified_at + .insert(provider, std::time::Instant::now()); + // The test already fetched the catalogue; keep it rather + // than making a second identical request. + this.apply_ai_catalog(provider, models, CatalogSource::Live, cx); + cx.notify(); + } + Err(error) => { + this.ai_connection.insert(provider, ConnectionState::Failed); + this.ai_connection_error + .insert(provider, connection_error_message(provider, &error)); + cx.notify(); + } + }) + .ok(); + })); + } + + /// Read `provider`'s cached catalogue and render it at once, revalidating + /// in the background only when it is not fresh. + pub(super) fn load_ai_catalog(&mut self, provider: AiProvider, cx: &mut Context) { + if let std::collections::btree_map::Entry::Vacant(e) = self.ai_catalog.entry(provider) { + let cached = catalog::read_cached(provider); + let fresh = cached.as_ref().map(|cached| { + catalog::freshness(cached.fetched_at, catalog::now_unix()) + == catalog::CatalogFreshness::Fresh + }); + let (models, source) = catalog::resolve_catalog(provider, cached); + e.insert(models); + self.ai_catalog_source.insert(provider, source); + self.sync_model_picker(cx); + if fresh == Some(true) { + return; + } + } else if matches!( + self.ai_catalog_source.get(&provider), + Some(CatalogSource::Live) + ) { + return; + } + + self.refresh_ai_catalog(provider, false, cx); + } + + /// Fetch `provider`'s catalogue in the background. + /// + /// A failure keeps the cached list on screen and reports itself inline. + /// Blanking the picker because a refresh failed would be strictly worse + /// than showing a slightly stale list. + pub(super) fn refresh_ai_catalog( + &mut self, + provider: AiProvider, + force: bool, + cx: &mut Context, + ) { + let key = cx.read_global::(|state, _cx| state.ai_api_key_for(provider)); + if catalog::catalog_needs_key(provider) && key.is_none() { + // Not an error: the user simply has not connected this provider + // yet, and the bundled list is already showing. + return; + } + if !force && self.ai_catalog_loading { + return; + } + + let client = cx.http_client(); + let generation = self.ai_catalog_generation.wrapping_add(1); + self.ai_catalog_generation = generation; + self.ai_catalog_loading = true; + cx.notify(); + + self.ai_catalog_task = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + // Send, parse and cache-write, all off the UI thread: the + // unfiltered OpenRouter payload is around 700 KB. + let fetched = cx + .background_executor() + .spawn(async move { + let models = catalog::fetch_models(provider, &client, key.as_deref()).await?; + let envelope = catalog::CachedCatalog { + schema: catalog::CATALOG_SCHEMA, + fetched_at: catalog::now_unix(), + models: models.clone(), + }; + if let Err(error) = catalog::write_cached(provider, &envelope) { + log::warn!("Failed to cache the {} model list: {}", provider, error); + } + anyhow::Ok(models) + }) + .await; + + this.update(cx, |this, cx| { + // Drop a superseded result, the same guard `apply_refresh_data` + // uses in `rgitui_git`. + if this.ai_catalog_generation != generation { + return; + } + this.ai_catalog_loading = false; + match fetched { + Ok(models) => { + this.ai_catalog_error = None; + this.apply_ai_catalog(provider, models, CatalogSource::Live, cx); + } + Err(error) => { + this.ai_catalog_error = Some(error.to_string()); + } + } + cx.notify(); + }) + .ok(); + })); + } + + fn apply_ai_catalog( + &mut self, + provider: AiProvider, + models: Vec, + source: CatalogSource, + cx: &mut Context, + ) { + self.ai_catalog.insert(provider, models); + self.ai_catalog_source.insert(provider, source); + self.sync_model_picker(cx); + } + + /// Feed the picker the current provider's catalogue. + pub(super) fn sync_model_picker(&mut self, cx: &mut Context) { + let provider = self.ai_provider; + let models = self.ai_catalog.get(&provider).cloned().unwrap_or_default(); + let filter = ModelFilter { + tools_only: self.ai_use_tools, + ..ModelFilter::default() + }; + let rows: Vec = filter_models(&models, filter) + .into_iter() + .map(|model| model_row(provider, model)) + .collect(); + + let footer = self + .ai_catalog_source + .get(&provider) + .map(|source| catalog_source_label(*source)); + let status = self.ai_catalog_error.clone().map(|error| { + format!( + "Couldn't refresh the model list — showing the last known {} models. {error}", + models.len() + ) + }); + let selected = self.ai_model.clone(); + + self.ai_model_picker.update(cx, |picker, cx| { + picker.set_rows(rows, cx); + picker.set_chips( + vec![ + PickerChip::new("", "All"), + PickerChip::new("cheap", "Cheap"), + PickerChip::new("tools", "Tools"), + PickerChip::new("free", "Free"), + ], + cx, + ); + picker.set_selected(Some(selected.into()), cx); + picker.set_footer_note(footer.map(SharedString::from), cx); + picker.set_status_note(status.map(SharedString::from), cx); + }); + } + + // ── Rendering ──────────────────────────────────────────────────── + + /// The sticky status strip. Lives in the header block, outside the scroll + /// child, so it never scrolls away and never shifts the layout. + pub(super) fn render_ai_status_strip(&self, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); + let provider = self.ai_provider; + let state = self.connection_state(provider); + let enabled = self.ai_enabled; + + let detail = if !enabled { + "AI is turned off. Nothing will be sent to any provider.".to_string() + } else { + match state { + ConnectionState::Connected => { + let verified = self + .ai_verified_at + .get(&provider) + .map(|at| format!(" · verified {}", relative_age(at.elapsed()))) + .unwrap_or_default(); + format!( + "{} · {}{}", + provider.display_name(), + self.ai_model, + verified + ) + } + ConnectionState::Testing => format!("Testing {}…", provider.display_name()), + ConnectionState::Failed => self + .ai_connection_error + .get(&provider) + .cloned() + .unwrap_or_else(|| format!("{} rejected this key.", provider.display_name())), + ConnectionState::Unconfigured => { + format!("Add a {} API key to get started.", provider.display_name()) + } + } + }; + + div() + .flex() + .flex_row() + .items_center() + .gap(px(12.)) + .w_full() + .p(px(10.)) + .rounded(px(8.)) + .bg(colors.element_background) + .child( + div().flex_1().min_w_0().child( + StatusPill::new( + "ai-status", + if enabled { + state + } else { + ConnectionState::Unconfigured + }, + "AI Commit Messages", + ) + .detail(detail), + ), + ) + .child( + div() + .id("ai-enabled-toggle") + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .flex_shrink_0() + .cursor_pointer() + .tab_index(SETTINGS_TAB_INDEX_BASE) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.ai_enabled = !this.ai_enabled; + this.save_settings(cx); + })) + .child( + Label::new("Enabled") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Checkbox::new( + "ai-enabled-cb", + if enabled { + CheckState::Checked + } else { + CheckState::Unchecked + }, + )), + ) + } + + /// The AI page body. + pub(super) fn render_ai_section(&mut self, cx: &mut Context) -> impl IntoElement { + let mut section = div().flex().flex_col().w_full().min_w_0().gap(px(16.)); + section = section.child(Self::section_label_row("CONNECTION")); + section = section.child(self.render_provider_accordion(cx)); + section = section.child(Self::section_label_row("BEHAVIOUR")); + section = section.child(self.render_behaviour_card(cx)); + section + } + + fn section_label_row(text: &'static str) -> impl IntoElement { + div().w_full().child( + Label::new(text) + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD) + .color(Color::Muted), + ) + } + + fn connection_state(&self, provider: AiProvider) -> ConnectionState { + if let Some(state) = self.ai_connection.get(&provider) { + return *state; + } + if self.provider_has_key(provider) { + // A stored key is "configured", never "verified" — the difference + // is the whole point of the Test button. + ConnectionState::Connected + } else { + ConnectionState::Unconfigured + } + } + + fn provider_has_key(&self, provider: AiProvider) -> bool { + self.ai_keys_loaded + .get(&provider) + .is_some_and(|key| !key.trim().is_empty()) + } + + fn render_provider_accordion(&mut self, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); + let mut card = Self::setting_card(cx).gap(px(2.)); + + for (index, provider) in AiProvider::ALL.iter().copied().enumerate() { + let is_expanded = self.expanded_ai_provider == Some(provider); + let is_active = self.ai_provider == provider; + let state = self.connection_state(provider); + // The header alone answers "which providers am I set up on, and + // which am I using?" without expanding anything. + let pinned_model = cx.read_global::(|settings, _cx| { + settings.settings().ai.model_for(provider) + }); + + let header = div() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .w_full() + // A comfortable hit target; the chevron and the row body share + // one click handler. + .min_h(px(32.)) + .child( + div().flex_shrink_0().child( + Disclosure::new( + ElementId::Name(format!("ai-provider-{}", provider.id()).into()), + provider.display_name(), + is_expanded, + ) + .tab_index(SETTINGS_TAB_INDEX_BASE + 1 + index as isize) + .on_toggle(cx.listener( + move |this, _: &ClickEvent, _, cx| { + this.toggle_ai_provider_row(provider, cx); + }, + )), + ), + ) + .child( + // The active provider is a radio, marked in the header + // rather than chosen from a separate pill row. + div().flex_shrink_0().child( + Icon::new(if is_active { + IconName::Sparkle + } else { + IconName::DotOutline + }) + .size(IconSize::XSmall) + .color(if is_active { + Color::Accent + } else { + Color::Muted + }), + ), + ) + .child(div().flex_1().min_w_0()) + .child(div().flex_shrink_0().child(StatusPill::for_state( + ElementId::Name(format!("ai-state-{}", provider.id()).into()), + state, + ))) + .child( + div().flex_shrink_0().min_w(px(140.)).child( + Label::new(if state == ConnectionState::Unconfigured { + SharedString::default() + } else { + SharedString::from(pinned_model) + }) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate(), + ), + ); + + let mut row = div() + .id(ElementId::Name( + format!("ai-provider-row-{}", provider.id()).into(), + )) + .flex() + .flex_col() + .w_full() + .min_w_0() + .px(px(6.)) + .py(px(4.)) + .rounded(px(6.)) + .when(is_expanded, |el| el.bg(colors.element_background)) + .child(header); + + if is_expanded { + row = row.child(self.render_provider_body(provider, index, cx)); + } + + card = card.child(row); + } + + card + } + + /// The expanded provider body — key field, model, and Advanced. + fn render_provider_body( + &mut self, + provider: AiProvider, + index: usize, + cx: &mut Context, + ) -> gpui::AnyElement { + let colors = cx.colors().clone(); + let state = self.connection_state(provider); + let has_key = self.provider_has_key(provider); + let tab_base = SETTINGS_TAB_INDEX_BASE + 20 + (index as isize * 10); + + // `div().flex().flex_col()`, never `v_flex()`/`h_flex()` here: the + // forced vertical centring in the shared helpers is a recurring cause + // of broken scroll containers and misaligned children. + let mut body = div() + .flex() + .flex_col() + .w_full() + .min_w_0() + .gap(px(10.)) + .pt(px(10.)) + .pl(px(20.)) + .pr(px(6.)) + .pb(px(6.)); + + if !has_key { + // A provider with no key opens onto onboarding, not an empty text + // box. This is the first thing a new user sees. + body = body.child( + div() + .flex() + .flex_col() + .items_center() + .gap(px(4.)) + .w_full() + .child( + Icon::new(IconName::Sparkle) + .size(IconSize::Medium) + .color(Color::Accent), + ) + .child( + Label::new("Connect an AI provider") + .size(LabelSize::Default) + .weight(FontWeight::SEMIBOLD), + ) + .child( + Label::new("rgitui writes commit messages from your staged diff.") + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ); + } + + body = body.child(self.render_key_field(provider, tab_base, cx)); + + // Status line under the field: what the app actually knows. + let status_line: Option = match state { + ConnectionState::Connected if has_key => Some( + match self.ai_verified_at.get(&provider) { + Some(at) => format!( + "Verified {} · stored in {}", + relative_age(at.elapsed()), + credential_store_name() + ), + None => format!( + "Key stored in {}. Test it to confirm it works.", + credential_store_name() + ), + } + .into(), + ), + ConnectionState::Testing => { + Some(format!("Testing {}…", self.ai_model).to_string().into()) + } + ConnectionState::Failed => self + .ai_connection_error + .get(&provider) + .cloned() + .map(SharedString::from), + _ => Some( + format!( + "Keys are stored in {}, never in settings.json, and are only read when a \ + request is sent.", + credential_store_name() + ) + .into(), + ), + }; + if let Some(line) = status_line { + body = body.child(Label::new(line).size(LabelSize::XSmall).color(match state { + ConnectionState::Failed => Color::Error, + ConnectionState::Connected => Color::Success, + _ => Color::Muted, + })); + } + + if has_key { + body = body.child(self.render_model_row(provider, tab_base, cx)); + } + + body = body.child(self.render_provider_actions(provider, has_key, tab_base, cx)); + + if provider.is_openai_compatible() { + body = body.child(self.render_advanced(provider, tab_base, cx)); + } + + body.border_t_1() + .border_color(colors.border_variant) + .into_any_element() + } + + fn render_key_field( + &self, + provider: AiProvider, + tab_base: isize, + cx: &mut Context, + ) -> gpui::AnyElement { + let Some(editor) = self.ai_key_editors.get(&provider).cloned() else { + return div().into_any_element(); + }; + let unmasked = self.is_field_unmasked(MaskedField::AiApiKey(provider)); + let tail = self + .ai_keys_loaded + .get(&provider) + .map(|key| masked_tail(key)) + .unwrap_or_default(); + + div() + .flex() + .flex_col() + .w_full() + .gap(px(4.)) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .child( + Label::new("API key") + .size(LabelSize::Small) + .weight(FontWeight::SEMIBOLD), + ) + .when(!tail.is_empty() && !unmasked, |el| { + // The last four characters answer "is this the right + // key?" without unmasking the whole thing. + el.child( + Label::new(tail.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }) + .child(div().flex_1()) + .child( + Button::new( + ElementId::Name(format!("ai-key-url-{}", provider.id()).into()), + "Get a key", + ) + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .icon(IconName::ExternalLink) + .color(Color::Accent) + .tab_index(tab_base + 3) + .on_click(move |_event, _window, cx| { + cx.open_url(provider.key_url()); + }), + ), + ) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .w_full() + .child( + // A lock, not a decorative eye two inches from a live + // Show button using the same glyph. + Icon::new(IconName::Lock) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(div().flex_1().min_w_0().child(editor)) + .child( + IconButton::new( + ElementId::Name(format!("ai-key-paste-{}", provider.id()).into()), + IconName::File, + ) + .size(ButtonSize::Compact) + .color(Color::Muted) + .tooltip("Paste from clipboard") + .tab_index(tab_base + 1) + .on_click(cx.listener( + move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.import_from_clipboard(MaskedField::AiApiKey(provider), cx); + }, + )), + ) + .child( + IconButton::new( + ElementId::Name(format!("ai-key-mask-{}", provider.id()).into()), + if unmasked { + IconName::EyeOff + } else { + IconName::Eye + }, + ) + .size(ButtonSize::Compact) + .color(Color::Muted) + .tooltip(if unmasked { "Hide" } else { "Show" }) + .tab_index(tab_base + 2) + .on_click(cx.listener( + move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.toggle_mask_visibility(MaskedField::AiApiKey(provider), cx); + }, + )), + ), + ) + .into_any_element() + } + + fn render_model_row( + &mut self, + provider: AiProvider, + tab_base: isize, + cx: &mut Context, + ) -> gpui::AnyElement { + let colors = cx.colors().clone(); + let models = self.ai_catalog.get(&provider).cloned().unwrap_or_default(); + let source = self + .ai_catalog_source + .get(&provider) + .copied() + .unwrap_or(CatalogSource::Bundled); + let pinned = if provider == self.ai_provider { + self.ai_model.clone() + } else { + cx.read_global::(|state, _cx| state.settings().ai.model_for(provider)) + }; + let status = classify_pinned(&pinned, &models, source, self.ai_use_tools); + let is_active_provider = provider == self.ai_provider; + + let summary: SharedString = match &status { + PinnedModelStatus::Known(model) => model.summary_line().into(), + _ => SharedString::default(), + }; + + let mut column = div() + .flex() + .flex_col() + .w_full() + .gap(px(4.)) + .child( + Label::new("Model") + .size(LabelSize::Small) + .weight(FontWeight::SEMIBOLD), + ) + .child( + div() + .id(ElementId::Name( + format!("ai-model-field-{}", provider.id()).into(), + )) + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .w_full() + .min_h(px(32.)) + .px(px(10.)) + .rounded(px(6.)) + .border_1() + .border_color(colors.border) + .bg(colors.editor_background) + .cursor_pointer() + .tab_index(tab_base + 4) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + if !is_active_provider { + this.use_ai_provider(provider, cx); + } + this.ai_model_picker_open = !this.ai_model_picker_open; + this.sync_model_picker(cx); + cx.notify(); + })) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + Label::new(SharedString::from(pinned.clone())) + .size(LabelSize::Small), + ) + .when(!summary.is_empty(), |el| { + el.child( + Label::new(summary.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }), + ) + .child( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ), + ); + + // Inline warnings, never a mutation: `settings.ai.model` is user + // intent, and silently retargeting it is how someone ends up billed + // for a model they did not choose. + match &status { + PinnedModelStatus::Missing { suggestion } => { + let mut warning = div().flex().flex_row().items_center().gap(px(6.)).child( + Label::new(format!( + "`{pinned}` is not in {}'s current model list. It may have been retired.", + provider.display_name() + )) + .size(LabelSize::XSmall) + .color(Color::Warning), + ); + if let Some(suggestion) = suggestion.clone() { + warning = warning.child( + Button::new( + ElementId::Name( + format!("ai-model-suggestion-{}", provider.id()).into(), + ), + format!("Use {suggestion}"), + ) + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .color(Color::Accent) + .on_click(cx.listener( + move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.select_ai_model(suggestion.clone(), cx); + }, + )), + ); + } + column = column.child(warning); + } + PinnedModelStatus::Incompatible { reason } => { + // The highest-value check: an opaque runtime 404 becomes a + // settings-time warning. + column = column.child( + Label::new(reason.clone()) + .size(LabelSize::XSmall) + .color(Color::Warning), + ); + } + // `Unverified` deliberately warns about nothing: a bundled or + // absent catalogue is not evidence a model is gone, and crying + // wolf offline is worse than staying quiet. + PinnedModelStatus::Unverified | PinnedModelStatus::Known(_) => {} + } + + column = column.child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .child( + Label::new(format!( + "{} models · {}", + models.len(), + catalog_source_label(source) + )) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child(div().flex_1()) + .child( + Button::new( + ElementId::Name(format!("ai-model-refresh-{}", provider.id()).into()), + if self.ai_catalog_loading { + "Refreshing…" + } else { + "Refresh" + }, + ) + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .icon(IconName::Refresh) + .color(Color::Muted) + .disabled(self.ai_catalog_loading) + .tab_index(tab_base + 5) + .on_click(cx.listener( + move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.refresh_ai_catalog(provider, true, cx); + }, + )), + ), + ); + + if self.ai_model_picker_open && is_active_provider { + column = column.child(self.ai_model_picker.clone()); + } + + column.into_any_element() + } + + fn render_provider_actions( + &self, + provider: AiProvider, + has_key: bool, + tab_base: isize, + cx: &mut Context, + ) -> gpui::AnyElement { + let is_active = self.ai_provider == provider; + let testing = self.connection_state(provider) == ConnectionState::Testing; + + let mut row = div() + .flex() + .flex_row() + .flex_wrap() + .items_center() + .gap(px(6.)) + .w_full() + .pt(px(4.)) + .child(div().flex_1()); + + if !has_key { + // "Connect" is the user's goal; "Save" never was. + return row + .child( + Button::new( + ElementId::Name(format!("ai-connect-{}", provider.id()).into()), + "Connect", + ) + .style(ButtonStyle::Filled) + .size(ButtonSize::Compact) + .color(Color::Accent) + .tab_index(tab_base + 6) + .on_click(cx.listener( + move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + // Save and test in one action. + this.flush_secret_save(cx); + this.use_ai_provider(provider, cx); + this.test_ai_connection(provider, cx); + }, + )), + ) + .into_any_element(); + } + + if !is_active { + row = row.child( + Button::new( + ElementId::Name(format!("ai-use-{}", provider.id()).into()), + "Use this provider", + ) + .style(ButtonStyle::Filled) + .size(ButtonSize::Compact) + .color(Color::Accent) + .tab_index(tab_base + 6) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.use_ai_provider(provider, cx); + })), + ); + } + + row.child( + Button::new( + ElementId::Name(format!("ai-test-{}", provider.id()).into()), + if testing { "Testing…" } else { "Test" }, + ) + .style(ButtonStyle::Outlined) + .size(ButtonSize::Compact) + .disabled(testing) + .tab_index(tab_base + 7) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.flush_secret_save(cx); + this.test_ai_connection(provider, cx); + })), + ) + .child( + Button::new( + ElementId::Name(format!("ai-remove-{}", provider.id()).into()), + "Remove key", + ) + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .color(Color::Error) + .tab_index(tab_base + 8) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.remove_ai_key(provider, cx); + })), + ) + .into_any_element() + } + + pub(super) fn remove_ai_key(&mut self, provider: AiProvider, cx: &mut Context) { + if let Some(editor) = self.ai_key_editors.get(&provider).cloned() { + editor.update(cx, |e, cx| e.clear(cx)); + } + self.ai_connection + .insert(provider, ConnectionState::Unconfigured); + self.ai_connection_error.remove(&provider); + self.ai_verified_at.remove(&provider); + self.flush_secret_save(cx); + cx.notify(); + } + + /// The `base_url_override` field, behind an Advanced disclosure so it does + /// not clutter the common path. + fn render_advanced( + &self, + provider: AiProvider, + tab_base: isize, + cx: &mut Context, + ) -> gpui::AnyElement { + let open = self.ai_advanced_open; + let mut column = div().flex().flex_col().w_full().gap(px(6.)).child( + Disclosure::new( + ElementId::Name(format!("ai-advanced-{}", provider.id()).into()), + "Advanced", + open, + ) + .tab_index(tab_base + 9) + .on_toggle(cx.listener(|this, _: &ClickEvent, _, cx| { + this.ai_advanced_open = !this.ai_advanced_open; + cx.notify(); + })), + ); + + if !open { + return column.into_any_element(); + } + + let host = rgitui_ai::effective_host(provider, &self.ai_base_url_override); + let overridden = !self.ai_base_url_override.trim().is_empty(); + + column = column + .child( + Label::new("Base URL") + .size(LabelSize::Small) + .weight(FontWeight::SEMIBOLD), + ) + .child(div().w_full().child(self.ai_base_url_editor.clone())) + .child( + Label::new(if overridden { + // Say plainly where the key goes. A user pointing this at + // a third-party gateway should see that stated. + format!( + "Requests go to {host} instead of {}. Your API key is sent to that host.", + provider.default_host() + ) + } else { + format!( + "Empty means use {}. Only OpenAI-compatible providers honour an override.", + provider.default_host() + ) + }) + .size(LabelSize::XSmall) + .color(if overridden { + Color::Warning + } else { + Color::Muted + }), + ); + + if provider == AiProvider::OpenRouter { + let attribution = self.ai_openrouter_attribution; + column = column.child( + div() + .id("ai-openrouter-attribution") + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .cursor_pointer() + .tab_index(tab_base + 10) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.ai_openrouter_attribution = !this.ai_openrouter_attribution; + this.save_settings(cx); + })) + .child(Checkbox::new( + "ai-openrouter-attribution-cb", + if attribution { + CheckState::Checked + } else { + CheckState::Unchecked + }, + )) + .child( + div() + .flex() + .flex_col() + .child(Label::new("Send attribution headers").size(LabelSize::Small)) + .child( + Label::new( + "Adds HTTP-Referer and X-Title so rgitui appears on \ + OpenRouter's public leaderboard. Never functional.", + ) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ), + ); + } + + column.into_any_element() + } + + fn render_behaviour_card(&mut self, cx: &mut Context) -> impl IntoElement { + let style = CommitStyle::from_id(&self.ai_commit_style).unwrap_or_default(); + let ids: Vec<&str> = CommitStyle::ALL.iter().map(|style| style.id()).collect(); + + Self::setting_card(cx) + .child(Self::setting_label( + "Commit style", + "How the AI should format commit messages.", + )) + .child(self.pill_group( + "commit-style", + &ids, + &self.ai_commit_style, + |this, value, cx| { + this.ai_commit_style = value; + this.save_settings(cx); + }, + cx, + )) + // A live example: the three labels are guesses until you see + // output, which makes this the highest-value line on the page. + .child( + Label::new(style.example()) + .size(LabelSize::XSmall) + .color(Color::Accent), + ) + .child(Self::section_divider(cx)) + .child(self.render_behaviour_toggle( + "ai-inject-ctx", + "Include project context", + "Adds README.md, CLAUDE.md and AGENTS.md to the prompt. ~4k extra tokens per request.", + self.ai_inject_project_context, + SETTINGS_TAB_INDEX_BASE + 80, + |this, cx| { + this.ai_inject_project_context = !this.ai_inject_project_context; + this.save_settings(cx); + }, + cx, + )) + .child(self.render_behaviour_toggle( + "ai-use-tools", + "Let the model read files", + "The model may request file contents and commit history. Slower and more \ + expensive; usually a better message.", + self.ai_use_tools, + SETTINGS_TAB_INDEX_BASE + 81, + |this, cx| { + this.ai_use_tools = !this.ai_use_tools; + this.sync_model_picker(cx); + this.save_settings(cx); + }, + cx, + )) + } + + /// A behaviour toggle that states what it costs. + /// + /// `use_tools` defaults to on, which means the out-of-box configuration is + /// the expensive multi-round-trip one; presenting that as an unremarkable + /// checkbox hid the tradeoff entirely. + #[allow(clippy::too_many_arguments)] + fn render_behaviour_toggle( + &self, + id: &'static str, + title: &'static str, + detail: &'static str, + checked: bool, + tab_index: isize, + on_toggle: impl Fn(&mut Self, &mut Context) + 'static, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(id) + .flex() + .flex_row() + .items_start() + .gap(px(10.)) + .w_full() + .min_w_0() + .cursor_pointer() + .tab_index(tab_index) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + on_toggle(this, cx); + })) + .child(div().flex_shrink_0().pt(px(1.)).child(Checkbox::new( + ElementId::Name(format!("{id}-cb").into()), + if checked { + CheckState::Checked + } else { + CheckState::Unchecked + }, + ))) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(2.)) + .child( + Label::new(title) + .size(LabelSize::Small) + .weight(FontWeight::SEMIBOLD), + ) + .child( + Label::new(detail) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + } +} + +/// A picker row for one model, with the facets its filter chips need. +fn model_row(provider: AiProvider, model: &ModelInfo) -> PickerRow { + let mut row = PickerRow::new(model.id.clone(), model.display_name.clone()) + .secondary(provider.display_name()) + .trailing(model.trailing_label()); + + if let Some(badge) = model.tool_support.badge() { + row = row.badge(badge); + if model.tool_support == rgitui_ai::catalog::ToolSupport::Supported { + row = row.facet("tools"); + } + } else { + // `Unknown` renders with no badge rather than a false one, but still + // belongs in the Tools chip: dropping it would empty the Gemini and + // OpenAI lists entirely. + row = row.facet("tools"); + } + + if model.is_free() { + row = row.badge("free").facet("free"); + } + // "Cheap" means under a dollar per million prompt tokens; a provider that + // reports no pricing is not claimed to be cheap. + if model.prompt_price_per_mtok.is_some_and(|price| price < 1.0) { + row = row.facet("cheap"); + } + row +} + +/// How the catalogue on screen was obtained, so "three weeks old" is +/// distinguishable from "shipped with the app". +fn catalog_source_label(source: CatalogSource) -> String { + match source { + CatalogSource::Live => "updated just now".to_string(), + CatalogSource::Cache { fetched_at } => { + let age = catalog::now_unix().saturating_sub(fetched_at).max(0) as u64; + format!( + "updated {}", + relative_age(std::time::Duration::from_secs(age)) + ) + } + CatalogSource::Bundled => "shipped with rgitui".to_string(), + } +} + +/// Map a failed connection test onto the sentence that says what to do. +fn connection_error_message(provider: AiProvider, error: &anyhow::Error) -> String { + let text = error.to_string(); + let name = provider.display_name(); + + // Order matters: a rate limit is transient and a bad key is not, so the + // 429 check must come before the auth check rather than being swallowed by + // it. + if text.contains("429") || text.contains("Rate limited") { + return format!("Rate limited by {name}. This usually clears in a minute."); + } + if text.contains("401") || text.contains("403") || text.contains("did not accept") { + let hint = match provider { + AiProvider::Gemini => " Keys usually start with \"AIza\".", + AiProvider::OpenAi => " Keys usually start with \"sk-\".", + AiProvider::Anthropic => " Keys usually start with \"sk-ant-\".", + AiProvider::OpenRouter => " Keys usually start with \"sk-or-\".", + // No hint rather than an invented one. + AiProvider::DeepSeek => "", + }; + return format!("{name} did not accept this key.{hint}"); + } + if text.contains("Couldn't reach") || text.contains("did not respond") { + return text; + } + format!("{name} could not be reached: {text}") +} + +#[cfg(test)] +mod tests { + use super::*; + use rgitui_ai::catalog::ToolSupport; + + fn model(id: &str) -> ModelInfo { + ModelInfo { + id: id.to_string(), + display_name: id.to_string(), + context_length: Some(128_000), + max_output_tokens: Some(4096), + prompt_price_per_mtok: None, + completion_price_per_mtok: None, + tool_support: ToolSupport::Supported, + emits_text: true, + is_variant: false, + created: None, + } + } + + #[test] + fn a_tool_capable_model_lands_in_the_tools_chip() { + let row = model_row(AiProvider::OpenAi, &model("gpt-5.6-luna")); + assert!(row.facets.iter().any(|facet| facet == "tools")); + assert!(row.badges.iter().any(|badge| badge == "Tools")); + } + + /// Dropping `Unknown` from the Tools chip would empty the Gemini and + /// OpenAI lists entirely, since neither advertises tool support. + #[test] + fn an_unknown_tool_capability_stays_in_the_chip_but_gets_no_badge() { + let mut info = model("gemini-3.1-flash-lite"); + info.tool_support = ToolSupport::Unknown; + let row = model_row(AiProvider::Gemini, &info); + assert!(row.facets.iter().any(|facet| facet == "tools")); + assert!(row.badges.is_empty()); + } + + #[test] + fn a_model_with_no_reported_price_is_not_claimed_to_be_cheap() { + let row = model_row(AiProvider::OpenAi, &model("gpt-5.6-luna")); + assert!(!row.facets.iter().any(|facet| facet == "cheap")); + } + + #[test] + fn cheap_and_free_are_derived_from_reported_pricing() { + let mut cheap = model("vendor/cheap"); + cheap.prompt_price_per_mtok = Some(0.25); + cheap.completion_price_per_mtok = Some(1.5); + let row = model_row(AiProvider::OpenRouter, &cheap); + assert!(row.facets.iter().any(|facet| facet == "cheap")); + assert!(!row.facets.iter().any(|facet| facet == "free")); + + let mut free = model("vendor/free"); + free.prompt_price_per_mtok = Some(0.0); + free.completion_price_per_mtok = Some(0.0); + let row = model_row(AiProvider::OpenRouter, &free); + assert!(row.facets.iter().any(|facet| facet == "free")); + assert!(row.badges.iter().any(|badge| badge == "free")); + } + + #[test] + fn an_expensive_model_is_not_in_the_cheap_chip() { + let mut expensive = model("vendor/opus"); + expensive.prompt_price_per_mtok = Some(15.0); + let row = model_row(AiProvider::OpenRouter, &expensive); + assert!(!row.facets.iter().any(|facet| facet == "cheap")); + } + + #[test] + fn the_catalogue_source_is_named_so_stale_is_distinguishable_from_bundled() { + assert_eq!( + catalog_source_label(CatalogSource::Live), + "updated just now" + ); + assert_eq!( + catalog_source_label(CatalogSource::Bundled), + "shipped with rgitui" + ); + let hours_ago = catalog::now_unix() - 3 * 60 * 60; + assert!(catalog_source_label(CatalogSource::Cache { + fetched_at: hours_ago + }) + .contains("h ago")); + } + + #[test] + fn an_auth_failure_names_the_key_prefix_the_provider_uses() { + let error = anyhow::anyhow!("Google Gemini rejected the request (401)"); + let message = connection_error_message(AiProvider::Gemini, &error); + assert!(message.contains("did not accept this key")); + assert!(message.contains("AIza")); + } + + #[test] + fn a_rate_limit_says_it_will_clear_rather_than_looking_permanent() { + let error = anyhow::anyhow!("OpenAI rejected the request (429)"); + let message = connection_error_message(AiProvider::OpenAi, &error); + assert!(message.contains("clears in a minute")); + // A transient limit must not be reported as a bad key. + assert!(!message.contains("did not accept")); + } + + #[test] + fn a_network_failure_is_passed_through_unchanged() { + let error = anyhow::anyhow!("Couldn't reach api.openai.com. Check your connection."); + assert_eq!( + connection_error_message(AiProvider::OpenAi, &error), + "Couldn't reach api.openai.com. Check your connection." + ); + } + + #[test] + fn deepseek_gets_no_fabricated_key_prefix_hint() { + let error = anyhow::anyhow!("DeepSeek rejected the request (401)"); + let message = connection_error_message(AiProvider::DeepSeek, &error); + assert!(message.contains("did not accept this key")); + assert!(!message.contains("start with")); + } +} diff --git a/crates/rgitui_workspace/src/settings_window/mod.rs b/crates/rgitui_workspace/src/settings_window/mod.rs index 39a96b86..26272556 100644 --- a/crates/rgitui_workspace/src/settings_window/mod.rs +++ b/crates/rgitui_workspace/src/settings_window/mod.rs @@ -18,6 +18,7 @@ //! render loop updates the in-memory state on resize/move; the on-disk //! write happens on window close to avoid per-frame keychain access. +mod ai_section; mod channel; mod events; mod view; diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index c776c33b..44f16eb9 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -18,15 +18,17 @@ use gpui::{ div, point, px, AnyElement, ClickEvent, Context, ElementId, Entity, EventEmitter, FontWeight, ScrollHandle, SharedString, Task, Window, }; +use rgitui_ai::catalog::{CatalogSource, ModelInfo}; use rgitui_settings::{ - config_dir, AiSettings, AppearanceMode, AutoFetchInterval, Compactness, DiffViewMode, + config_dir, AiProvider, AppearanceMode, AutoFetchInterval, Compactness, DiffViewMode, GitProviderSettings, GraphStyle, SettingsState, }; use rgitui_theme::{ActiveTheme, Color, StyledExt, ThemeState}; use rgitui_ui::{ - Button, ButtonSize, ButtonStyle, CheckState, Checkbox, Icon, IconButton, IconName, IconSize, - Label, LabelSize, TextInput, TextInputEvent, + Button, ButtonSize, ButtonStyle, CheckState, Checkbox, ConnectionState, Icon, IconName, + IconSize, Label, LabelSize, Picker, TextInput, TextInputEvent, }; +use std::collections::BTreeMap; use super::events::SettingsViewEvent; use super::{SettingsWindowAction, SettingsWindowActionGlobal}; @@ -50,12 +52,100 @@ const QUICK_REFERENCE_COMMANDS: &[CommandId] = &[ CommandId::Settings, ]; +/// Where the AI page's tab order starts. +/// +/// `tab_index` appeared zero times across this file, so roughly nine of ten +/// controls on the page were mouse-only even though `Button`, `Checkbox` and +/// `Disclosure` all already supported it. +pub(super) const SETTINGS_TAB_INDEX_BASE: isize = 100; + const SETTINGS_SIDEBAR_WIDTH: f32 = 220.; const SETTINGS_CONTENT_PADDING: f32 = 28.; +/// Clean up a secret pasted from the clipboard, or say why it was refused. +/// +/// Pure and testable. The old code took `text.lines().next()`, so a key that +/// arrived wrapped across lines was silently truncated while the UI reported a +/// successful import — and then failed with a 401 at request time. +pub(super) fn sanitize_pasted_secret(text: &str) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err("The clipboard did not contain any text."); + } + if trimmed.lines().count() > 1 { + return Err("That looks like more than one line. Copy just the key and try again."); + } + if trimmed.contains(char::is_whitespace) { + return Err("That contains spaces. Copy just the key and try again."); + } + Ok(trimmed.to_string()) +} + +/// The last four characters of a secret, masked — `••••ZK7q`. +/// +/// This is what makes "is this the right key?" answerable without unmasking +/// the whole thing. +pub(super) fn masked_tail(secret: &str) -> String { + let trimmed = secret.trim(); + if trimmed.is_empty() { + return String::new(); + } + let tail: String = trimmed + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("••••{tail}") +} + +/// A coarse "2 min ago" for a verification timestamp. +pub(super) fn relative_age(elapsed: std::time::Duration) -> String { + let seconds = elapsed.as_secs(); + match seconds { + 0..=44 => "just now".to_string(), + 45..=5399 => format!("{} min ago", (seconds + 30) / 60), + 5400..=86_399 => format!("{} h ago", (seconds + 1800) / 3600), + _ => format!("{} d ago", (seconds + 43_200) / 86_400), + } +} + +/// The option `delta` steps away from `selected`, or `None` when the move +/// would run off either end. +/// +/// Clamps rather than wrapping: a radio group that jumps from the last option +/// back to the first makes it easy to overshoot a setting without noticing. +pub(super) fn adjacent_option(options: &[String], selected: &str, delta: isize) -> Option { + let current = options.iter().position(|option| option == selected)?; + let next = current as isize + delta; + if next < 0 || next >= options.len() as isize { + return None; + } + options.get(next as usize).cloned() +} + +/// The name of the OS credential store, for the reassurance line under the key +/// field. +pub(super) fn credential_store_name() -> &'static str { + #[cfg(target_os = "windows")] + { + "Windows Credential Manager" + } + #[cfg(target_os = "macos")] + { + "the macOS Keychain" + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + "your system keyring" + } +} + /// Which section of the settings is currently active. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SettingsSection { +pub(super) enum SettingsSection { Theme, Ai, Auth, @@ -63,8 +153,9 @@ enum SettingsSection { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MaskedField { - AiApiKey, +pub(super) enum MaskedField { + /// Each provider owns its own key field, so the variant carries which. + AiApiKey(AiProvider), GitHttpsToken, ProviderToken, } @@ -249,22 +340,55 @@ fn detect_available_editors() -> Vec { /// The settings view. pub struct SettingsView { - active_section: SettingsSection, + pub(super) active_section: SettingsSection, // Theme state selected_theme: String, available_themes: Vec, // AI state - ai_provider: String, - ai_model: String, - ai_commit_style: String, - ai_enabled: bool, - ai_inject_project_context: bool, - ai_use_tools: bool, - - // AI text editors - ai_api_key_editor: Entity, + pub(super) ai_provider: AiProvider, + pub(super) ai_model: String, + pub(super) ai_commit_style: String, + pub(super) ai_enabled: bool, + pub(super) ai_inject_project_context: bool, + pub(super) ai_use_tools: bool, + pub(super) ai_base_url_override: String, + pub(super) ai_openrouter_attribution: bool, + + /// One key editor per provider. A single shared field is what let + /// switching provider overwrite the previous provider's key. + pub(super) ai_key_editors: BTreeMap>, + /// The key text as loaded, so an unchanged field can skip the keychain + /// write entirely — which is most of the cost of a save. + pub(super) ai_keys_loaded: BTreeMap, + pub(super) ai_keys_unmasked: BTreeMap, + /// Exactly one provider expands at a time, mirroring the Git page's + /// accordion. + pub(super) expanded_ai_provider: Option, + pub(super) ai_advanced_open: bool, + pub(super) ai_base_url_editor: Entity, + + /// Result of the last connection test, per provider. + pub(super) ai_connection: BTreeMap, + pub(super) ai_connection_error: BTreeMap, + pub(super) ai_verified_at: BTreeMap, + pub(super) ai_test_task: Option>, + + /// The live model catalogue, per provider, with where it came from. + pub(super) ai_catalog: BTreeMap>, + pub(super) ai_catalog_source: BTreeMap, + pub(super) ai_catalog_loading: bool, + pub(super) ai_catalog_error: Option, + /// Drops superseded catalogue results, the same guard `apply_refresh_data` + /// uses in `rgitui_git`. + pub(super) ai_catalog_generation: u64, + pub(super) ai_catalog_task: Option>, + pub(super) ai_model_picker: Entity, + pub(super) ai_model_picker_open: bool, + /// Debounces the keychain write so it happens once per pause in typing + /// rather than once per keystroke. + pub(super) pending_secret_save: Option>, // Git state git_sign_commits: bool, @@ -272,7 +396,6 @@ pub struct SettingsView { selected_provider_index: usize, expanded_provider_kind: Option, pending_browser_auth_provider_id: Option, - show_ai_api_key: bool, show_git_https_token: bool, show_provider_token: bool, @@ -288,6 +411,8 @@ pub struct SettingsView { provider_token_editor: Entity, // General state + /// Auto-fetch interval, chosen from a dropdown rather than a pill row. + auto_fetch_select: Entity, max_recent_repos: usize, compactness: Compactness, font_size: u32, @@ -372,10 +497,6 @@ impl SettingsView { (settings, themes) }); - let ai_api_key_val = cx - .global::() - .ai_api_key() - .unwrap_or_default(); let git_https_token_val = cx .global::() .git_https_token() @@ -406,15 +527,121 @@ impl SettingsView { }) .collect(); - let ai_api_key_editor = cx.new(|cx| { - let mut ti = TextInput::new(cx); - ti.set_placeholder("Click to enter API key..."); - ti.set_masked(true); - if !ai_api_key_val.is_empty() { - ti.set_text(&ai_api_key_val, cx); + // One editor per provider, each populated from that provider's own + // keychain slot. Sharing a single field is what made a provider switch + // destroy the previous provider's key and then assert "connected" for + // one it had no credential for. + let mut ai_key_editors = BTreeMap::new(); + let mut ai_keys_loaded = BTreeMap::new(); + for provider in AiProvider::ALL { + let existing = cx + .global::() + .ai_api_key_for(*provider) + .unwrap_or_default(); + let placeholder = format!("Paste your {} API key", provider.display_name()); + let editor = cx.new(|cx| { + let mut input = TextInput::new(cx); + input.set_placeholder(placeholder); + input.set_masked(true); + if !existing.is_empty() { + input.set_text(&existing, cx); + } + input + }); + cx.subscribe(&editor, { + let provider = *provider; + move |this: &mut Self, _, event: &TextInputEvent, cx| match event { + // Debounced rather than per-keystroke, so the OS keychain + // is not round-tripped for every character typed. + TextInputEvent::Changed(_) => this.schedule_secret_save(cx), + // Enter and blur both flush: typing a key and clicking away + // used to discard it silently, while pasting the same key + // saved it immediately. + TextInputEvent::Submit | TextInputEvent::Blurred => { + this.flush_secret_save(cx); + if let TextInputEvent::Submit = event { + this.test_ai_connection(provider, cx); + } + } + } + }) + .detach(); + ai_keys_loaded.insert(*provider, existing); + ai_key_editors.insert(*provider, editor); + } + + let ai_base_url_editor = cx.new(|cx| { + let mut input = TextInput::new(cx); + input.set_placeholder("https://my-gateway.example.com/v1"); + if !settings.ai.base_url_override.is_empty() { + input.set_text(&settings.ai.base_url_override, cx); } - ti + input }); + cx.subscribe( + &ai_base_url_editor, + |this: &mut Self, _, event: &TextInputEvent, cx| match event { + TextInputEvent::Changed(text) => { + this.ai_base_url_override = text.clone(); + cx.notify(); + } + TextInputEvent::Submit | TextInputEvent::Blurred => { + this.commit_base_url_override(cx); + } + }, + ) + .detach(); + + let auto_fetch_select = cx.new(|cx| { + let mut select = rgitui_ui::Select::new("auto-fetch-select", cx); + select.set_options( + AutoFetchInterval::ALL + .iter() + .map(|interval| { + rgitui_ui::SelectOption::new(interval.to_string(), interval.to_string()) + }) + .collect(), + cx, + ); + select.set_selected(Some(settings.auto_fetch_interval.to_string().into()), cx); + select + }); + cx.subscribe( + &auto_fetch_select, + |this: &mut Self, _, event: &rgitui_ui::SelectEvent, cx| { + if let rgitui_ui::SelectEvent::Changed(id) = event { + // An unparseable id can only mean the option list and this + // handler have drifted, so keep the current value rather + // than silently resetting the user's choice. + if let Ok(interval) = id.parse::() { + this.auto_fetch_interval = interval; + this.save_settings(cx); + } + } + }, + ) + .detach(); + + let ai_model_picker = cx.new(Picker::new); + cx.subscribe( + &ai_model_picker, + |this: &mut Self, _, event: &rgitui_ui::PickerEvent, cx| match event { + rgitui_ui::PickerEvent::Selected(id) => { + this.select_ai_model(id.to_string(), cx); + } + rgitui_ui::PickerEvent::Dismissed => { + this.ai_model_picker_open = false; + cx.notify(); + } + rgitui_ui::PickerEvent::RefreshRequested => { + this.refresh_ai_catalog(this.ai_provider, true, cx); + } + rgitui_ui::PickerEvent::ChipChanged(_) => { + this.sync_model_picker(cx); + } + }, + ) + .detach(); let git_https_token_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); @@ -533,16 +760,6 @@ impl SettingsView { }) .detach(); - cx.subscribe( - &ai_api_key_editor, - |this: &mut Self, _, event: &TextInputEvent, cx| { - if let TextInputEvent::Submit = event { - this.save_settings(cx); - } - }, - ) - .detach(); - cx.subscribe( &git_https_token_editor, |this: &mut Self, _, event: &TextInputEvent, cx| { @@ -582,7 +799,10 @@ impl SettingsView { provider.display_name = text.clone(); } } - TextInputEvent::Submit => { + // Blur commits as well as Enter: the page autosaved every + // checkbox but discarded typed text on close, so the same + // value persisted or vanished depending on how it arrived. + TextInputEvent::Submit | TextInputEvent::Blurred => { this.save_settings(cx); } }, @@ -598,7 +818,10 @@ impl SettingsView { provider.host = text.clone(); } } - TextInputEvent::Submit => { + // Blur commits as well as Enter: the page autosaved every + // checkbox but discarded typed text on close, so the same + // value persisted or vanished depending on how it arrived. + TextInputEvent::Submit | TextInputEvent::Blurred => { this.save_settings(cx); } }, @@ -614,7 +837,10 @@ impl SettingsView { provider.username = text.clone(); } } - TextInputEvent::Submit => { + // Blur commits as well as Enter: the page autosaved every + // checkbox but discarded typed text on close, so the same + // value persisted or vanished depending on how it arrived. + TextInputEvent::Submit | TextInputEvent::Blurred => { this.save_settings(cx); } }, @@ -631,7 +857,7 @@ impl SettingsView { provider.has_token = !provider.token.is_empty(); } } - TextInputEvent::Submit => { + TextInputEvent::Submit | TextInputEvent::Blurred => { this.complete_browser_onboarding_for_current_provider(); this.save_settings(cx); } @@ -656,7 +882,10 @@ impl SettingsView { .position(|app| app.command == *text); } } - TextInputEvent::Submit => { + // Blur commits as well as Enter: the page autosaved every + // checkbox but discarded typed text on close, so the same + // value persisted or vanished depending on how it arrived. + TextInputEvent::Submit | TextInputEvent::Blurred => { this.save_settings(cx); } }, @@ -680,7 +909,10 @@ impl SettingsView { .position(|app| app.command == *text); } } - TextInputEvent::Submit => { + // Blur commits as well as Enter: the page autosaved every + // checkbox but discarded typed text on close, so the same + // value persisted or vanished depending on how it arrived. + TextInputEvent::Submit | TextInputEvent::Blurred => { this.save_settings(cx); } }, @@ -691,19 +923,38 @@ impl SettingsView { active_section: SettingsSection::Theme, selected_theme: settings.theme.clone(), available_themes, - ai_provider: settings.ai.provider.clone(), + ai_provider: settings.ai.provider, ai_model: settings.ai.model.clone(), ai_commit_style: settings.ai.commit_style.clone(), ai_enabled: settings.ai.enabled, ai_inject_project_context: settings.ai.inject_project_context, ai_use_tools: settings.ai.use_tools, - ai_api_key_editor, + ai_base_url_override: settings.ai.base_url_override.clone(), + ai_openrouter_attribution: settings.ai.openrouter_attribution, + ai_key_editors, + ai_keys_loaded, + ai_keys_unmasked: BTreeMap::new(), + expanded_ai_provider: None, + ai_advanced_open: false, + ai_base_url_editor, + ai_connection: BTreeMap::new(), + ai_connection_error: BTreeMap::new(), + ai_verified_at: BTreeMap::new(), + ai_test_task: None, + ai_catalog: BTreeMap::new(), + ai_catalog_source: BTreeMap::new(), + ai_catalog_loading: false, + ai_catalog_error: None, + ai_catalog_generation: 0, + ai_catalog_task: None, + ai_model_picker, + ai_model_picker_open: false, + pending_secret_save: None, git_sign_commits: settings.git.sign_commits, git_providers, selected_provider_index: 0, expanded_provider_kind: None, pending_browser_auth_provider_id: None, - show_ai_api_key: false, show_git_https_token: false, show_provider_token: false, git_https_token_editor, @@ -722,6 +973,7 @@ impl SettingsView { graph_style: settings.graph_style, show_subject_column: settings.show_subject_column, auto_fetch_interval: settings.auto_fetch_interval, + auto_fetch_select, confirm_destructive_operations: settings.confirm_destructive_operations, auto_check_updates: settings.auto_check_updates, watch_all_worktrees: settings.watch_all_worktrees, @@ -743,16 +995,25 @@ impl SettingsView { } pub(super) fn reload_from_settings(&mut self, cx: &mut Context) { - let (ai_api_key_val, git_https_token_val, git_ssh_key_path_val, git_gpg_key_id_val) = - cx.read_global::(|state, _cx| { + let mut ai_keys: BTreeMap = BTreeMap::new(); + let (git_https_token_val, git_ssh_key_path_val, git_gpg_key_id_val) = cx + .read_global::(|state, _cx| { let s = state.settings(); self.selected_theme = s.theme.clone(); - self.ai_provider = s.ai.provider.clone(); + self.ai_provider = s.ai.provider; self.ai_model = s.ai.model.clone(); self.ai_commit_style = s.ai.commit_style.clone(); self.ai_enabled = s.ai.enabled; self.ai_inject_project_context = s.ai.inject_project_context; self.ai_use_tools = s.ai.use_tools; + self.ai_base_url_override = s.ai.base_url_override.clone(); + self.ai_openrouter_attribution = s.ai.openrouter_attribution; + for provider in AiProvider::ALL { + ai_keys.insert( + *provider, + state.ai_api_key_for(*provider).unwrap_or_default(), + ); + } self.git_sign_commits = s.git.sign_commits; self.git_providers = s .git @@ -779,8 +1040,9 @@ impl SettingsView { .selected_provider_index .min(self.git_providers.len().saturating_sub(1)); self.expanded_provider_kind = None; + self.expanded_ai_provider = None; self.pending_browser_auth_provider_id = None; - self.show_ai_api_key = false; + self.ai_keys_unmasked.clear(); self.show_git_https_token = false; self.show_provider_token = false; self.max_recent_repos = s.max_recent_repos; @@ -799,7 +1061,6 @@ impl SettingsView { self.feedback_message = None; self.feedback_is_error = false; ( - state.ai_api_key().unwrap_or_default(), state.git_https_token().unwrap_or_default(), s.git.ssh_key_path.clone().unwrap_or_default(), s.git.gpg_key_id.clone().unwrap_or_default(), @@ -818,8 +1079,19 @@ impl SettingsView { self.recompute_selected_indices(&terminal_cmd, &editor_cmd); - self.ai_api_key_editor - .update(cx, |e, cx| e.set_text(ai_api_key_val, cx)); + for (provider, key) in ai_keys { + if let Some(editor) = self.ai_key_editors.get(&provider).cloned() { + editor.update(cx, |e, cx| e.set_text(key.clone(), cx)); + } + self.ai_keys_loaded.insert(provider, key); + } + let base_url = self.ai_base_url_override.clone(); + self.ai_base_url_editor + .update(cx, |e, cx| e.set_text(base_url, cx)); + let interval = self.auto_fetch_interval.to_string(); + self.auto_fetch_select.update(cx, |select, cx| { + select.set_selected(Some(interval.into()), cx) + }); self.git_https_token_editor .update(cx, |e, cx| e.set_text(git_https_token_val, cx)); self.git_ssh_key_path_editor @@ -895,7 +1167,12 @@ impl SettingsView { /// Show a feedback message in the header banner. Informational and success /// messages auto-dismiss after a few seconds; errors stay until replaced or /// dismissed so the user has time to read them. - fn set_feedback(&mut self, message: impl Into, is_error: bool, cx: &mut Context) { + pub(super) fn set_feedback( + &mut self, + message: impl Into, + is_error: bool, + cx: &mut Context, + ) { self.feedback_generation = self.feedback_generation.wrapping_add(1); self.feedback_message = Some(message.into()); self.feedback_is_error = is_error; @@ -949,8 +1226,20 @@ impl SettingsView { /// Persist all settings. Returns `true` when the save succeeded so callers /// can surface explicit confirmation. - fn save_settings(&mut self, cx: &mut Context) -> bool { - let ai_api_key = self.ai_api_key_editor.read(cx).text().to_string(); + pub(super) fn save_settings(&mut self, cx: &mut Context) -> bool { + // Collect only the keys whose editor text differs from what was + // loaded. Skipping the unchanged ones removes almost the whole cost of + // a save: every checkbox click used to issue a dozen synchronous + // credential-store operations on the render thread. + let changed_keys: Vec<(AiProvider, String)> = AiProvider::ALL + .iter() + .filter_map(|provider| { + let editor = self.ai_key_editors.get(provider)?; + let text = editor.read(cx).text().trim().to_string(); + let loaded = self.ai_keys_loaded.get(provider).map(String::as_str); + (loaded != Some(text.as_str())).then_some((*provider, text)) + }) + .collect(); let git_https_token = self.git_https_token_editor.read(cx).text().to_string(); let git_ssh_key_path = self.git_ssh_key_path_editor.read(cx).text().to_string(); let git_gpg_key_id = self.git_gpg_key_id_editor.read(cx).text().to_string(); @@ -958,16 +1247,35 @@ impl SettingsView { let editor_command = self.editor_command_editor.read(cx).text().to_string(); let result = cx.update_global::(|state, _cx| -> anyhow::Result<()> { - state.settings_mut().ai = AiSettings { - provider: self.ai_provider.clone(), - legacy_api_key: None, - has_api_key: !ai_api_key.trim().is_empty(), - model: self.ai_model.clone(), - commit_style: self.ai_commit_style.clone(), - enabled: self.ai_enabled, - inject_project_context: self.ai_inject_project_context, - use_tools: self.ai_use_tools, - }; + // Every keychain write happens first. Mutating the global before + // the write meant a failed `set_password` left the in-memory + // provider pointing at one vendor while the resolved key was still + // the previous vendor's — and the next request sent that live + // credential to the wrong host. + for (provider, key) in &changed_keys { + state + .set_ai_api_key_for(*provider, Some(key.as_str()).filter(|k| !k.is_empty()))?; + } + state.set_git_https_token(Some(git_https_token.trim()).filter(|v| !v.is_empty()))?; + + { + let ai = &mut state.settings_mut().ai; + ai.legacy_api_key = None; + ai.provider = self.ai_provider; + ai.model = self.ai_model.clone(); + ai.models_by_provider + .insert(self.ai_provider.id().to_string(), self.ai_model.clone()); + ai.commit_style = self.ai_commit_style.clone(); + ai.enabled = self.ai_enabled; + ai.inject_project_context = self.ai_inject_project_context; + ai.use_tools = self.ai_use_tools; + ai.base_url_override = self.ai_base_url_override.trim().to_string(); + ai.openrouter_attribution = self.ai_openrouter_attribution; + // `has_api_key` mirrors the active provider's flag, which + // `set_ai_api_key_for` has already recorded from what the + // keychain actually accepted. + ai.has_api_key = ai.has_key_for(self.ai_provider); + } state.settings_mut().max_recent_repos = self.max_recent_repos; state.settings_mut().compactness = self.compactness; state.settings_mut().font_size = self.font_size; @@ -1000,9 +1308,6 @@ impl SettingsView { git.git.sign_commits = self.git_sign_commits; } - state.set_ai_api_key(Some(ai_api_key.trim()).filter(|v| !v.is_empty()))?; - state.set_git_https_token(Some(git_https_token.trim()).filter(|v| !v.is_empty()))?; - // Write provider tokens to keychain BEFORE replacing providers, // so that sync_auth_runtime inside replace_git_providers can // resolve the tokens from the keychain immediately. @@ -1040,6 +1345,12 @@ impl SettingsView { let succeeded = match result { Ok(()) => { + // Only now that the keychain accepted them does the "loaded" + // baseline move, so a failed write is retried next save rather + // than being skipped as unchanged. + for (provider, key) in changed_keys { + self.ai_keys_loaded.insert(provider, key); + } self.clear_feedback(cx); SettingsWindowActionGlobal::try_send(cx, SettingsWindowAction::SettingsChanged); true @@ -1269,21 +1580,26 @@ impl SettingsView { self.save_settings(cx); } - fn is_field_unmasked(&self, field: MaskedField) -> bool { + pub(super) fn is_field_unmasked(&self, field: MaskedField) -> bool { match field { - MaskedField::AiApiKey => self.show_ai_api_key, + MaskedField::AiApiKey(provider) => self + .ai_keys_unmasked + .get(&provider) + .copied() + .unwrap_or(false), MaskedField::GitHttpsToken => self.show_git_https_token, MaskedField::ProviderToken => self.show_provider_token, } } - fn toggle_mask_visibility(&mut self, field: MaskedField, cx: &mut Context) { + pub(super) fn toggle_mask_visibility(&mut self, field: MaskedField, cx: &mut Context) { match field { - MaskedField::AiApiKey => { - self.show_ai_api_key = !self.show_ai_api_key; - let masked = !self.show_ai_api_key; - self.ai_api_key_editor - .update(cx, |e, _cx| e.set_masked(masked)); + MaskedField::AiApiKey(provider) => { + let unmasked = !self.is_field_unmasked(field); + self.ai_keys_unmasked.insert(provider, unmasked); + if let Some(editor) = self.ai_key_editors.get(&provider).cloned() { + editor.update(cx, |e, _cx| e.set_masked(!unmasked)); + } } MaskedField::GitHttpsToken => { self.show_git_https_token = !self.show_git_https_token; @@ -1301,7 +1617,7 @@ impl SettingsView { cx.notify(); } - fn import_from_clipboard(&mut self, field: MaskedField, cx: &mut Context) { + pub(super) fn import_from_clipboard(&mut self, field: MaskedField, cx: &mut Context) { let Some(clipboard) = cx.read_from_clipboard() else { self.set_feedback("Clipboard did not contain text.", true, cx); return; @@ -1312,11 +1628,21 @@ impl SettingsView { return; }; - let imported = text.lines().next().unwrap_or("").trim().to_string(); + // A multi-line paste used to be silently truncated to its first line + // while reporting success, so a wrapped key looked accepted and then + // 401'd at request time. + let imported = match sanitize_pasted_secret(&text) { + Ok(secret) => secret, + Err(message) => { + self.set_feedback(message, true, cx); + return; + } + }; match field { - MaskedField::AiApiKey => { - self.ai_api_key_editor - .update(cx, |e, cx| e.set_text(&imported, cx)); + MaskedField::AiApiKey(provider) => { + if let Some(editor) = self.ai_key_editors.get(&provider).cloned() { + editor.update(cx, |e, cx| e.set_text(&imported, cx)); + } } MaskedField::GitHttpsToken => { self.git_https_token_editor @@ -1564,13 +1890,15 @@ impl SettingsView { } // ── Helper: setting group card ────────────────────────────────────── - fn setting_card(cx: &Context) -> gpui::Div { + pub(super) fn setting_card(cx: &Context) -> gpui::Div { let colors = cx.colors().clone(); div() .v_flex() .w_full() .min_w_0() - .w_full() + // Clip at the card, so the next overflow reads as a card bug + // rather than escaping to be sliced at the window edge. + .overflow_hidden() .p(px(16.)) .gap(px(12.)) .rounded(px(8.)) @@ -1579,13 +1907,13 @@ impl SettingsView { .border_color(colors.border_variant) } - fn section_divider(cx: &Context) -> gpui::Div { + pub(super) fn section_divider(cx: &Context) -> gpui::Div { let colors = cx.colors().clone(); div().w_full().h(px(1.)).bg(colors.border_variant) } // ── Helper: setting row label ─────────────────────────────────────── - fn setting_label(title: &str, description: &str) -> impl IntoElement { + pub(super) fn setting_label(title: &str, description: &str) -> impl IntoElement { div() .v_flex() .gap(px(2.)) @@ -1602,7 +1930,11 @@ impl SettingsView { } // ── Helper: pill toggle group ─────────────────────────────────────── - fn pill_group( + /// + /// The container is a single tab stop and Left/Right move within it, which + /// is the radio-group convention — putting every pill in the tab order + /// would make a four-option row cost four tabs to skip. + pub(super) fn pill_group( &self, group_id: &str, options: &[&str], @@ -1613,14 +1945,52 @@ impl SettingsView { let on_select = std::rc::Rc::new(on_select); let options_vec: Vec = options.iter().map(|o| o.to_string()).collect(); let selected_str = selected.to_string(); - let colors = cx.colors(); + let colors = cx.colors().clone(); + + // `flex_wrap()`, and `div().flex().flex_row()` rather than `h_flex()`. + // + // Flex children default to `min-width: auto`, so a `Label` cannot + // shrink below its min-content width: without wrapping, this row grew + // past its card and was finally sliced at the window edge. The default + // window could not render the default provider's own model list. + // `h_flex()` is `flex_row().items_center()`, and once a row wraps that + // forced centring breaks the moment any pill grows a second line. + let keyboard_options = options_vec.clone(); + let keyboard_selected = selected_str.clone(); + let keyboard_select = on_select.clone(); let mut row = div() - .h_flex() + .id(ElementId::Name(SharedString::from(format!( + "pill-group-{group_id}" + )))) + .flex() + .flex_row() + .flex_wrap() + .items_start() .gap(px(4.)) .p(px(3.)) .rounded(px(8.)) - .bg(colors.element_background); + .bg(colors.element_background) + .tab_index(0) + .focus_visible({ + let focused = colors.border_focused; + move |style: gpui::StyleRefinement| style.border_color(focused) + }) + .border_1() + .border_color(gpui::transparent_black()) + .on_key_down(cx.listener(move |this, event: &gpui::KeyDownEvent, _, cx| { + let delta: isize = match event.keystroke.key.as_str() { + "left" | "up" => -1, + "right" | "down" => 1, + _ => return, + }; + let Some(next) = adjacent_option(&keyboard_options, &keyboard_selected, delta) + else { + return; + }; + cx.stop_propagation(); + keyboard_select(this, next, cx); + })); for (idx, option) in options_vec.into_iter().enumerate() { let is_selected = option == selected_str; @@ -1774,7 +2144,7 @@ impl SettingsView { ), ); - for (section, icon, label) in sections { + for (index, (section, icon, label)) in sections.into_iter().enumerate() { let is_active = section == self.active_section; let label_str: SharedString = label.into(); @@ -1792,6 +2162,13 @@ impl SettingsView { .items_center() .rounded(px(6.)) .cursor_pointer() + // The nav comes before the page body in the tab order, so + // Tab from the top reaches the sections first. + .tab_index(index as isize + 1) + .focus_visible({ + let focused = colors.border_focused; + move |style: gpui::StyleRefinement| style.border_color(focused) + }) .when(is_active, |el| { el.bg(colors.ghost_element_selected) .border_l_2() @@ -2029,253 +2406,6 @@ impl SettingsView { section } - // ── AI section ────────────────────────────────────────────────────── - fn render_ai_section(&self, cx: &mut Context) -> impl IntoElement { - let mut section = div().v_flex().w_full().gap(px(16.)); - - section = section.child(Self::section_header( - IconName::Sparkle, - "AI Configuration", - "Configure AI-powered commit message generation.", - )); - - // Enable/disable card - let ai_enabled = self.ai_enabled; - let mut enable_card = Self::setting_card(cx); - enable_card = enable_card.child( - div() - .h_flex() - .w_full() - .items_center() - .child( - div() - .v_flex() - .flex_1() - .gap(px(2.)) - .child( - Label::new("Enable AI") - .size(LabelSize::Small) - .weight(FontWeight::SEMIBOLD), - ) - .child( - Label::new("Generate commit messages using AI") - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .child( - div() - .id("ai-toggle") - .cursor_pointer() - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - this.ai_enabled = !this.ai_enabled; - this.save_settings(cx); - })) - .child(Checkbox::new( - "ai-enabled-cb", - if ai_enabled { - CheckState::Checked - } else { - CheckState::Unchecked - }, - )), - ), - ); - section = section.child(enable_card); - - // Inject project context card - let inject_ctx = self.ai_inject_project_context; - let mut inject_ctx_card = Self::setting_card(cx); - inject_ctx_card = inject_ctx_card.child( - div() - .h_flex() - .w_full() - .items_center() - .child( - div() - .v_flex() - .flex_1() - .gap(px(2.)) - .child( - Label::new("Inject project context") - .size(LabelSize::Small) - .weight(FontWeight::SEMIBOLD), - ) - .child( - Label::new( - "Include README.md, CLAUDE.md, and AGENTS.md in the AI prompt", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .child( - div() - .id("ai-inject-ctx-toggle") - .cursor_pointer() - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - this.ai_inject_project_context = !this.ai_inject_project_context; - this.save_settings(cx); - })) - .child(Checkbox::new( - "ai-inject-ctx-cb", - if inject_ctx { - CheckState::Checked - } else { - CheckState::Unchecked - }, - )), - ), - ); - section = section.child(inject_ctx_card); - - // Use tools card - let use_tools = self.ai_use_tools; - let mut use_tools_card = Self::setting_card(cx); - use_tools_card = use_tools_card.child( - div() - .h_flex() - .w_full() - .items_center() - .child( - div() - .v_flex() - .flex_1() - .gap(px(2.)) - .child( - Label::new("Use AI tools") - .size(LabelSize::Small) - .weight(FontWeight::SEMIBOLD), - ) - .child( - Label::new( - "Allow the AI to request file contents and commit history for better messages", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .child( - div() - .id("ai-use-tools-toggle") - .cursor_pointer() - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - this.ai_use_tools = !this.ai_use_tools; - this.save_settings(cx); - })) - .child(Checkbox::new( - "ai-use-tools-cb", - if use_tools { - CheckState::Checked - } else { - CheckState::Unchecked - }, - )), - ), - ); - section = section.child(use_tools_card); - - // Provider + Model card - let mut provider_card = Self::setting_card(cx); - provider_card = provider_card - .child(Self::setting_label("Provider", "Choose your AI provider.")) - .child(self.pill_group( - "provider", - &["gemini", "openai", "anthropic", "deepseek"], - &self.ai_provider, - |this, value, cx| { - this.ai_provider = value; - // Reset model when provider changes - this.ai_model = match this.ai_provider.as_str() { - "gemini" => "gemini-3-flash-preview".into(), - "openai" => "gpt-5-mini".into(), - "anthropic" => "claude-sonnet-4-6".into(), - "deepseek" => "deepseek-v4-flash".into(), - _ => "gemini-3-flash-preview".into(), - }; - this.save_settings(cx); - }, - cx, - )); - - // Model selector - let models: Vec<&str> = match self.ai_provider.as_str() { - "gemini" => vec![ - "gemini-3.1-pro-preview", - "gemini-3-flash-preview", - "gemini-2.5-flash", - "gemini-2.5-pro", - ], - "openai" => vec![ - "gpt-5.4", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "o3", - "o4-mini", - ], - "anthropic" => vec![ - "claude-opus-4-6", - "claude-sonnet-4-6", - "claude-sonnet-4-5-20241022", - "claude-haiku-4-5", - ], - "deepseek" => vec!["deepseek-v4-flash", "deepseek-v4-pro"], - _ => vec!["gemini-2.5-flash"], - }; - provider_card = provider_card - .child(Self::setting_label("Model", "Select the model to use.")) - .child(self.pill_group( - "model", - &models, - &self.ai_model, - |this, value, cx| { - this.ai_model = value; - this.save_settings(cx); - }, - cx, - )); - - section = section.child(provider_card); - - // Commit style card - let mut style_card = Self::setting_card(cx); - style_card = style_card - .child(Self::setting_label( - "Commit Style", - "How the AI should format commit messages.", - )) - .child(self.pill_group( - "commit-style", - &["conventional", "descriptive", "brief"], - &self.ai_commit_style, - |this, value, cx| { - this.ai_commit_style = value; - this.save_settings(cx); - }, - cx, - )); - section = section.child(style_card); - - // API Key card - let mut key_card = Self::setting_card(cx); - key_card = key_card - .child(Self::setting_label( - "API Key", - "Stored in your OS keychain and only materialized in memory when needed.", - )) - .child(self.masked_editor_row( - "ai-api-key-input", - &self.ai_api_key_editor, - MaskedField::AiApiKey, - IconName::Eye, - cx, - )); - section = section.child(key_card); - - section - } - // ── Git section ────────────────────────────────────────────────────── fn render_git_section(&self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); @@ -3487,23 +3617,10 @@ impl SettingsView { "Auto-Fetch Interval", "How often to automatically fetch from remotes in the background.", )) - .child(self.pill_group( - "auto-fetch", - &["Disabled", "1 min", "5 min", "15 min", "30 min"], - &self.auto_fetch_interval.to_string(), - |this, value, cx| { - this.auto_fetch_interval = match value.as_str() { - "Disabled" => AutoFetchInterval::Disabled, - "1 min" => AutoFetchInterval::OneMinute, - "5 min" => AutoFetchInterval::FiveMinutes, - "15 min" => AutoFetchInterval::FifteenMinutes, - "30 min" => AutoFetchInterval::ThirtyMinutes, - _ => AutoFetchInterval::Disabled, - }; - this.save_settings(cx); - }, - cx, - )), + // A `Select`, not a pill row: five fixed-width pills is the + // widest closed choice on the page, and unlike commit style + // there is nothing to compare side by side. + .child(self.auto_fetch_select.clone()), ); section = section.child(fetch_card); @@ -4063,21 +4180,24 @@ impl SettingsView { let sidebar = self.render_sidebar(cx); let colors = cx.colors(); + // The title names the page. Every section previously rendered the + // literal string "Preferences", so the most prominent text on screen + // carried no information at all. let (page_title, page_subtitle) = match self.active_section { SettingsSection::Theme => ( - "Preferences", + "Appearance", "Theme, layout, and visual defaults for the application.", ), SettingsSection::Ai => ( - "Preferences", - "Provider, model, and keychain-backed AI settings.", + "AI", + "Providers, models, and how commit messages get written.", ), SettingsSection::Auth => ( - "Preferences", + "Accounts", "Account profiles, HTTPS tokens, and SSH configuration.", ), SettingsSection::General => ( - "Preferences", + "General", "General application behavior and workspace defaults.", ), }; @@ -4106,16 +4226,36 @@ impl SettingsView { .truncate(), ), ) + // A fixed-height slot, so the confirmation cannot shift the page. + // The old banner was injected into the header column and pushed + // the whole scroll body down on every save. + // + // There is no Save button: every control on this page commits on + // change, and text inputs now flush on Enter and on blur. A + // prominent button devoted to one input's event wiring described a + // bug rather than a feature. .child( - Button::new("settings-save", "Save") - .style(ButtonStyle::Filled) - .color(Color::Accent) - .size(ButtonSize::Default) - .icon(IconName::Check) - .tooltip("Commit any unsubmitted text fields and save all settings") - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.commit_pending_edits(cx); - })), + div() + .flex() + .flex_row() + .items_center() + .justify_end() + .flex_shrink_0() + .w(px(200.)) + .h(px(20.)) + .when_some(self.feedback_message.clone(), |el, message| { + let is_error = self.feedback_is_error; + el.child( + Label::new(if is_error { + message + } else { + "Saved".to_string() + }) + .size(LabelSize::XSmall) + .color(if is_error { Color::Error } else { Color::Muted }) + .truncate(), + ) + }), ); let mut header = div() @@ -4129,58 +4269,12 @@ impl SettingsView { .border_color(colors.border_variant) .child(title_row); - if let Some(message) = &self.feedback_message { - let is_error = self.feedback_is_error; - header = header.child( - div() - .w_full() - .h_flex() - .gap(px(8.)) - .items_center() - .p(px(10.)) - .rounded(px(8.)) - .bg(if is_error { - cx.status().error_background - } else { - cx.status().success_background - }) - .border_1() - .border_color(if is_error { - cx.status().error - } else { - cx.status().success - }) - .child( - Icon::new(if is_error { - IconName::X - } else { - IconName::Check - }) - .size(IconSize::Small) - .color(if is_error { - Color::Error - } else { - Color::Success - }), - ) - .child( - div().flex_1().min_w_0().child( - Label::new(message.clone()) - .size(LabelSize::Small) - .color(Color::Default), - ), - ) - .child( - IconButton::new("dismiss-feedback", IconName::Close) - .style(ButtonStyle::Transparent) - .size(ButtonSize::Compact) - .color(Color::Muted) - .tooltip("Dismiss") - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.clear_feedback(cx); - })), - ), - ); + // The sticky status strip lives here, in the header block outside the + // scroll child, so it never scrolls away and never shifts the layout. + // The inline banner it replaces was injected into this column and + // pushed the whole scroll body down on every save. + if self.active_section == SettingsSection::Ai { + header = header.child(self.render_ai_status_strip(cx)); } div() @@ -4213,3 +4307,132 @@ impl SettingsView { .into_any_element() } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── pasted secrets ──────────────────────────────────────────── + + #[test] + fn a_clean_secret_is_trimmed_and_accepted() { + assert_eq!( + sanitize_pasted_secret(" sk-ant-abc123 "), + Ok("sk-ant-abc123".to_string()) + ); + } + + /// The old code took `text.lines().next()`, so a key that arrived wrapped + /// across lines was silently truncated while the UI reported success — and + /// then 401'd at request time. + #[test] + fn a_multi_line_paste_is_refused_rather_than_silently_truncated() { + let result = sanitize_pasted_secret("sk-ant-abc\n123def"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("more than one line")); + } + + #[test] + fn a_paste_with_embedded_spaces_is_refused() { + assert!(sanitize_pasted_secret("sk-ant abc").is_err()); + } + + #[test] + fn an_empty_paste_says_so() { + assert!(sanitize_pasted_secret("").is_err()); + assert!(sanitize_pasted_secret(" \n ").is_err()); + } + + #[test] + fn a_trailing_newline_alone_does_not_make_a_paste_multi_line() { + assert_eq!( + sanitize_pasted_secret("sk-ant-abc123\n"), + Ok("sk-ant-abc123".to_string()) + ); + } + + // ── masked tail ─────────────────────────────────────────────── + + /// The last four characters are what make "is this the right key?" + /// answerable without unmasking the whole secret. + #[test] + fn the_masked_tail_shows_only_the_last_four_characters() { + assert_eq!(masked_tail("sk-ant-api03-longvalueZK7q"), "••••ZK7q"); + } + + #[test] + fn a_short_secret_still_masks_its_prefix() { + assert_eq!(masked_tail("ab"), "••••ab"); + } + + #[test] + fn an_absent_secret_renders_nothing_rather_than_bare_dots() { + assert_eq!(masked_tail(""), ""); + assert_eq!(masked_tail(" "), ""); + } + + #[test] + fn a_multibyte_secret_does_not_split_a_character() { + // Four chars, not four bytes. + assert_eq!(masked_tail("key-日本語で"), "••••日本語で"); + } + + // ── relative age ────────────────────────────────────────────── + + #[test] + fn a_verification_age_reads_the_way_a_person_would_say_it() { + use std::time::Duration; + assert_eq!(relative_age(Duration::from_secs(0)), "just now"); + assert_eq!(relative_age(Duration::from_secs(44)), "just now"); + assert_eq!(relative_age(Duration::from_secs(60)), "1 min ago"); + assert_eq!(relative_age(Duration::from_secs(120)), "2 min ago"); + assert_eq!(relative_age(Duration::from_secs(3 * 3600)), "3 h ago"); + assert_eq!(relative_age(Duration::from_secs(3 * 86_400)), "3 d ago"); + } + + #[test] + fn relative_age_rounds_to_the_nearest_unit_rather_than_truncating() { + use std::time::Duration; + assert_eq!(relative_age(Duration::from_secs(90)), "2 min ago"); + assert_eq!(relative_age(Duration::from_secs(100 * 60)), "2 h ago"); + } + + // ── pill-group keyboard navigation ──────────────────────────── + + #[test] + fn arrow_keys_step_through_a_pill_group() { + let options: Vec = ["conventional", "descriptive", "brief"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!( + adjacent_option(&options, "conventional", 1).as_deref(), + Some("descriptive") + ); + assert_eq!( + adjacent_option(&options, "brief", -1).as_deref(), + Some("descriptive") + ); + } + + /// Clamping rather than wrapping: jumping from the last option back to the + /// first makes it easy to overshoot a setting without noticing. + #[test] + fn arrow_keys_clamp_at_both_ends() { + let options: Vec = ["a", "b"].iter().map(|s| s.to_string()).collect(); + assert_eq!(adjacent_option(&options, "b", 1), None); + assert_eq!(adjacent_option(&options, "a", -1), None); + } + + #[test] + fn a_selection_that_is_not_in_the_group_has_no_neighbour() { + let options: Vec = ["a", "b"].iter().map(|s| s.to_string()).collect(); + assert_eq!(adjacent_option(&options, "z", 1), None); + assert_eq!(adjacent_option(&[], "a", 1), None); + } + + #[test] + fn the_credential_store_is_named_for_this_platform() { + assert!(!credential_store_name().is_empty()); + } +} diff --git a/crates/rgitui_workspace/src/settings_window/window.rs b/crates/rgitui_workspace/src/settings_window/window.rs index 1f96f650..7e572282 100644 --- a/crates/rgitui_workspace/src/settings_window/window.rs +++ b/crates/rgitui_workspace/src/settings_window/window.rs @@ -100,10 +100,22 @@ impl SettingsWindow { /// from here and cannot be dismissed from here. fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { match cmd { - CommandId::Cancel => window.remove_window(), + CommandId::Cancel => self.close(window, cx), _ => cx.propagate(), } } + + /// Close the window, flushing any text field whose edit has not yet been + /// committed. + /// + /// Controls autosave on change and text inputs flush on Enter and on blur, + /// so this is a safety net rather than the primary path — but closing a + /// window is not a way to discard a pasted secret. + fn close(&mut self, window: &mut Window, cx: &mut Context) { + self.view + .update(cx, |view, cx| view.commit_pending_edits(cx)); + window.remove_window(); + } } impl Render for SettingsWindow { @@ -140,8 +152,8 @@ impl Render for SettingsWindow { .style(ButtonStyle::Subtle) .size(ButtonSize::Default) .icon(IconName::X) - .on_click(cx.listener(|_, _: &ClickEvent, window, _| { - window.remove_window(); + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { + this.close(window, cx); })), ), ) diff --git a/crates/rgitui_workspace/src/stash_branch_dialog.rs b/crates/rgitui_workspace/src/stash_branch_dialog.rs index 0ff96295..f962f08e 100644 --- a/crates/rgitui_workspace/src/stash_branch_dialog.rs +++ b/crates/rgitui_workspace/src/stash_branch_dialog.rs @@ -55,6 +55,7 @@ impl StashBranchDialog { TextInputEvent::Submit => { this.try_create(cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(text) => { this.error_message = Self::validate_branch_name(text); cx.notify(); diff --git a/crates/rgitui_workspace/src/stash_save_dialog.rs b/crates/rgitui_workspace/src/stash_save_dialog.rs index d6bb2429..498624ff 100644 --- a/crates/rgitui_workspace/src/stash_save_dialog.rs +++ b/crates/rgitui_workspace/src/stash_save_dialog.rs @@ -54,6 +54,7 @@ impl StashSaveDialog { TextInputEvent::Submit => { this.try_create(cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(text) => { this.error_message = Self::validate_message(text); cx.notify(); diff --git a/crates/rgitui_workspace/src/tag_dialog.rs b/crates/rgitui_workspace/src/tag_dialog.rs index e38bdd43..5da8152f 100644 --- a/crates/rgitui_workspace/src/tag_dialog.rs +++ b/crates/rgitui_workspace/src/tag_dialog.rs @@ -48,6 +48,7 @@ impl TagDialog { TextInputEvent::Submit => { this.try_create(cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(text) => { this.error_message = if text.is_empty() { None diff --git a/crates/rgitui_workspace/src/toast.rs b/crates/rgitui_workspace/src/toast.rs index 675d75b3..d49ca9c5 100644 --- a/crates/rgitui_workspace/src/toast.rs +++ b/crates/rgitui_workspace/src/toast.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::rc::Rc; use gpui::prelude::*; use gpui::{div, px, Context, ElementId, Render, WeakEntity, Window}; @@ -12,6 +12,8 @@ struct ToastEntry { id: usize, message: String, kind: ToastKind, + /// An optional single action, e.g. `[Open Settings]` or `[Retry]`. + action: Option<(String, Rc)>, } /// Manages a stack of transient toast notifications. @@ -28,36 +30,69 @@ impl ToastLayer { } } - /// Show a new toast notification. It will auto-dismiss after 3 seconds. + /// Show a new toast notification. + /// + /// How long it stays is decided by its level: an error is sticky, because + /// one hardcoded three-second timeout meant errors vanished before they + /// could be read. pub fn show_toast( &mut self, message: impl Into, kind: ToastKind, cx: &mut Context, + ) { + self.push(message.into(), kind, None, cx); + } + + /// Show a toast carrying a single action. + pub fn show_toast_with_action( + &mut self, + message: impl Into, + kind: ToastKind, + action_label: impl Into, + on_action: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, + cx: &mut Context, + ) { + let action = Some(( + action_label.into(), + Rc::new(Box::new(on_action) as rgitui_ui::ClickHandler), + )); + self.push(message.into(), kind, action, cx); + } + + fn push( + &mut self, + message: String, + kind: ToastKind, + action: Option<(String, Rc)>, + cx: &mut Context, ) { let id = self.next_id; self.next_id += 1; self.toasts.push(ToastEntry { id, - message: message.into(), + message, kind, + action, }); while self.toasts.len() > 3 { self.toasts.remove(0); } - cx.spawn( - async move |this: WeakEntity, cx: &mut gpui::AsyncApp| { - cx.background_executor().timer(Duration::from_secs(3)).await; - this.update(cx, |this, cx| { - this.dismiss_toast(id, cx); - }) - .ok(); - }, - ) - .detach(); + if let Some(after) = kind.auto_dismiss_after() { + cx.spawn( + async move |this: WeakEntity, cx: &mut gpui::AsyncApp| { + cx.background_executor().timer(after).await; + this.update(cx, |this, cx| { + this.dismiss_toast(id, cx); + }) + .ok(); + }, + ) + .detach(); + } cx.notify(); } @@ -88,17 +123,31 @@ impl Render for ToastLayer { let toast_id = entry.id; let entity = cx.entity().downgrade(); + let mut toast = Toast::new( + ElementId::NamedInteger("toast".into(), toast_id as u64), + entry.message.clone(), + entry.kind, + ); + if let Some((label, handler)) = &entry.action { + let handler = handler.clone(); + let dismiss_entity = cx.entity().downgrade(); + toast = toast.action(label.clone(), move |event, window, cx| { + handler(event, window, cx); + // Acting on a toast is also dismissing it; leaving it on + // screen would invite a second, now-pointless click. + dismiss_entity + .update(cx, |this, cx| this.dismiss_toast(toast_id, cx)) + .ok(); + }); + } + stack = stack.child( div() .id(ElementId::NamedInteger("toast-row".into(), toast_id as u64)) .h_flex() .items_center() .gap(px(0.)) - .child(div().flex_1().min_w_0().child(Toast::new( - ElementId::NamedInteger("toast".into(), toast_id as u64), - entry.message.clone(), - entry.kind, - ))) + .child(div().flex_1().min_w_0().child(toast)) .child( div() .id(ElementId::NamedInteger( @@ -137,51 +186,62 @@ impl Render for ToastLayer { mod tests { use super::*; + fn entry(id: usize, message: &str, kind: ToastKind) -> ToastEntry { + ToastEntry { + id, + message: message.to_string(), + kind, + action: None, + } + } + + #[test] + fn a_toast_entry_carries_its_message_and_level() { + let success = entry(0, "test message", ToastKind::Success); + assert_eq!(success.message, "test message"); + assert_eq!(success.kind, ToastKind::Success); + assert!(success.action.is_none()); + + assert_eq!( + entry(1, "error occurred", ToastKind::Error).kind, + ToastKind::Error + ); + } + + /// A three-second timeout on every level meant errors vanished before + /// they could be read, and a "Generating..." notice expired mid-operation + /// on a tool-calling generation that runs 30s or more. #[test] - fn test_toast_entry_kinds() { - // ToastEntry carries a message and kind; verify the kind field works - // ToastKind is an alias for ToastLevel, which has Success/Error/Warning/Info - let entry = ToastEntry { - id: 0, - message: "test message".to_string(), - kind: ToastKind::Success, - }; - assert_eq!(entry.message, "test message"); - assert_eq!(entry.kind, ToastKind::Success); - - let entry_err = ToastEntry { - id: 1, - message: "error occurred".to_string(), - kind: ToastKind::Error, - }; - assert_eq!(entry_err.kind, ToastKind::Error); + fn errors_stay_until_dismissed_while_lesser_levels_expire() { + assert_eq!(ToastKind::Error.auto_dismiss_after(), None); + assert_eq!( + ToastKind::Warning.auto_dismiss_after(), + Some(std::time::Duration::from_secs(6)) + ); + assert_eq!( + ToastKind::Info.auto_dismiss_after(), + Some(std::time::Duration::from_secs(3)) + ); + assert_eq!( + ToastKind::Success.auto_dismiss_after(), + Some(std::time::Duration::from_secs(3)) + ); } #[test] - fn test_toast_layer_new_is_empty() { - // Cannot construct ToastLayer without Context, but we can verify - // ToastKind and ToastEntry structural correctness - let entry = ToastEntry { - id: 0, - message: "loading".to_string(), - kind: ToastKind::Info, - }; - assert_eq!(entry.id, 0); - assert_eq!(entry.message, "loading"); - assert_eq!(entry.kind, ToastKind::Info); + fn a_warning_lingers_longer_than_an_info() { + let warning = ToastKind::Warning.auto_dismiss_after().unwrap(); + let info = ToastKind::Info.auto_dismiss_after().unwrap(); + assert!(warning > info); } #[test] - fn test_toast_level_color_mapping() { - // ToastKind is ToastLevel from rgitui_ui; verify the color() method exists + fn every_level_maps_to_a_distinct_colour_and_icon() { assert_eq!(ToastKind::Success.color(), Color::Success); assert_eq!(ToastKind::Error.color(), Color::Error); assert_eq!(ToastKind::Warning.color(), Color::Warning); assert_eq!(ToastKind::Info.color(), Color::Info); - } - #[test] - fn test_toast_level_icon_mapping() { assert_eq!(ToastKind::Success.icon(), IconName::CheckCircle); assert_eq!(ToastKind::Error.icon(), IconName::XCircle); assert_eq!(ToastKind::Warning.icon(), IconName::AlertTriangle); diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index 76603069..0c90eec1 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -426,6 +426,9 @@ impl Workspace { }); } CommandId::AiMessage => { + // Emitting the panel's own event routes through the same + // guarded handler the button uses, so the keyboard cannot + // start a request the button would have refused. tab.commit_panel.update(cx, |_cp, cx| { cx.emit(CommitPanelEvent::GenerateAiMessage); }); diff --git a/crates/rgitui_workspace/src/workspace/events.rs b/crates/rgitui_workspace/src/workspace/events.rs index 9e23cf2a..d86f2953 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -5,8 +5,8 @@ use std::sync::Mutex; use std::time::Instant; use futures::StreamExt; -use gpui::{AppContext, Context, Entity, SharedString}; -use rgitui_ai::{AiEvent, AiGenerator}; +use gpui::{AppContext, BorrowAppContext, Context, Entity, SharedString}; +use rgitui_ai::{AiEvent, AiGenerator, GenerationId}; use rgitui_diff::{ConflictResolution, DiffOperation, DiffSource, DiffViewer, DiffViewerEvent}; use rgitui_git::{ CommitInfo, GitOperationKind, GitOperationState, GitProject, GitProjectEvent, @@ -325,34 +325,94 @@ pub(super) fn subscribe_interactive_rebase( .detach(); } +/// The commit panel a generation was started for, found by the repo path the +/// request carried. +/// +/// Routing by `active_tab` meant that switching tabs mid-generation wrote the +/// message describing repo `foo`'s staged diff into repo `bar`'s commit box, +/// and left `foo`'s spinner running forever. +fn commit_panel_for( + workspace: &Workspace, + id: &GenerationId, + cx: &gpui::App, +) -> Option> { + workspace + .tabs + .iter() + .find(|tab| tab.effective_repo_path(cx) == id.repo_path) + .map(|tab| tab.commit_panel.clone()) +} + pub(super) fn subscribe_ai(cx: &mut Context, ai: &Entity) { - cx.subscribe(ai, |this, _ai, event: &AiEvent, cx| match event { - AiEvent::GenerationCompleted(message) => { - if let Some(tab) = this.tabs.get(this.active_tab) { - let msg = message.clone(); - tab.commit_panel.update(cx, |cp, cx| { - cp.set_message(msg, cx); - cp.set_ai_generating(false, cx); - }); - } + cx.subscribe(ai, |this, ai, event: &AiEvent, cx| match event { + AiEvent::GenerationStarted(id) => { + if let Some(panel) = commit_panel_for(this, id, cx) { + panel.update(cx, |cp, cx| cp.begin_ai_generation(cx)); + } + // The status bar alone. A toast here was a third signal for one + // event, and it expired at 3s while a tool-calling generation runs + // 30s or more. + this.set_status_message("Generating AI commit message...", cx); } - AiEvent::GenerationFailed(err) => { - log::error!("AI generation failed: {}", err); - let msg = format!("AI error: {}", err); - this.set_status_message(msg.clone(), cx); - this.show_toast(msg, ToastKind::Error, cx); - if let Some(tab) = this.tabs.get(this.active_tab) { - tab.commit_panel.update(cx, |cp, cx| { - cp.set_ai_generating(false, cx); - }); + AiEvent::ToolCallStarted(id, description) => { + // Routed to the chip as well as the status bar: this text is the + // real progress trace ("Reading diff.rs"), and it used to go only + // to the least-watched surface in the app. + if let Some(panel) = commit_panel_for(this, id, cx) { + let description = description.clone(); + panel.update(cx, |cp, cx| cp.set_ai_progress(Some(description), cx)); } - } - AiEvent::ToolCallStarted(description) => { this.set_status_message(format!("AI: {}", description), cx); } - AiEvent::GenerationStarted => { - this.set_status_message("Generating AI commit message...", cx); - this.show_toast("Generating AI commit message...", ToastKind::Info, cx); + AiEvent::GenerationCompleted(id, message) => { + let Some(panel) = commit_panel_for(this, id, cx) else { + // The tab closed mid-flight. Nothing to write to, and nothing + // to report. + return; + }; + let message = message.clone(); + panel.update(cx, |cp, cx| cp.apply_ai_message(message, cx)); + this.set_status_message("AI commit message ready.", cx); + } + AiEvent::GenerationFailed(id, error) => { + log::error!("AI generation failed: {}", error); + if let Some(panel) = commit_panel_for(this, id, cx) { + panel.update(cx, |cp, cx| cp.fail_ai_generation(cx)); + } + this.set_status_message(error.clone(), cx); + // Sticky, and carrying the fix: an AI failure is almost always a + // key or a model choice, both of which live one click away. + let workspace = cx.entity().downgrade(); + this.show_toast_with_action( + error.clone(), + ToastKind::Error, + "Open Settings", + move |_event, _window, cx| { + workspace + .update(cx, |this, cx| this.open_ai_settings(cx)) + .ok(); + }, + cx, + ); + } + AiEvent::GenerationCancelled(id) => { + if let Some(panel) = commit_panel_for(this, id, cx) { + panel.update(cx, |cp, cx| cp.fail_ai_generation(cx)); + } + this.set_status_message("AI generation cancelled.", cx); + } + AiEvent::RateLimited { wait } => { + // Info, not an error, and deliberately without touching any + // panel's spinner: reporting the cooldown as a failure used to + // clear the indicator of a request that was still running. + let _ = ai; + this.set_status_message( + format!( + "Waiting {}s before the next AI request.", + wait.as_secs() + 1 + ), + cx, + ); } }) .detach(); @@ -2821,38 +2881,16 @@ pub(super) fn subscribe_commit_panel( } } CommitPanelEvent::GenerateAiMessage => { - commit_panel_ref.update(cx, |cp, cx| { - cp.set_ai_generating(true, cx); - }); - - // Describe the checkout the commit will land in. Reading the - // main repository here made "generate message" summarise the main - // checkout's staged changes while the commit went to the worktree. - let repo_path = this.effective_worktree_path(cx); - let summary = project.read(cx).staged_summary_at(&repo_path); - let ai_entity = ai.clone(); - let diff_repo_path = repo_path.clone(); - let settings_state = cx.global::(); - let use_tools = settings_state.settings().ai.use_tools; - cx.spawn(async move |_, cx: &mut gpui::AsyncApp| { - let diff_text = cx - .background_executor() - .spawn(async move { - rgitui_git::compute_staged_diff_text(&diff_repo_path) - .unwrap_or_default() - }) - .await; - cx.update(|cx| { - ai_entity.update(cx, |ai_gen, cx| { - ai_gen - .generate_commit_message_with_tools( - diff_text, summary, repo_path, use_tools, cx, - ) - .detach(); - }); - }); - }) - .detach(); + start_ai_generation(this, &project, &ai, None, cx); + } + CommitPanelEvent::RegenerateAiMessage { style } => { + start_ai_generation(this, &project, &ai, *style, cx); + } + CommitPanelEvent::CancelAiMessage => { + ai.update(cx, |generator, cx| generator.cancel(cx)); + } + CommitPanelEvent::OpenAiSettings => { + this.open_ai_settings(cx); } CommitPanelEvent::CollapsedChanged => cx.notify(), } @@ -2860,6 +2898,101 @@ pub(super) fn subscribe_commit_panel( .detach(); } +/// Start a generation for the active tab. +/// +/// The one place a request is built, so the button, Ctrl+G and the command +/// palette cannot disagree about the guards. Previously only the button +/// checked `ai.enabled` and `has_api_key`, so Ctrl+G with AI turned off still +/// fired a full request and spent tokens. +/// +/// `style_override` regenerates in a different commit style for this one +/// request without changing the saved preference. +pub(super) fn start_ai_generation( + workspace: &mut Workspace, + project: &Entity, + ai: &Entity, + style_override: Option, + cx: &mut Context, +) { + let Some(tab) = workspace.tabs.get(workspace.active_tab) else { + return; + }; + let commit_panel = tab.commit_panel.clone(); + + let settings_state = cx.global::(); + let settings = settings_state.settings(); + let blocker = crate::commit_panel::ai_blocker( + settings.ai.enabled, + settings_state.has_ai_api_key(), + commit_panel.read(cx).staged_count(), + ); + let use_tools = settings.ai.use_tools; + + match blocker { + Some(crate::commit_panel::AiBlocker::NothingStaged) => { + workspace.set_status_message("Stage some changes first.", cx); + return; + } + Some(blocker) => { + // Both remaining cases are one click from fixed, so say so and + // offer the click rather than only reporting the problem. + let handle = cx.entity().downgrade(); + workspace.show_toast_with_action( + blocker.tooltip(), + ToastKind::Warning, + "Open Settings", + move |_event, _window, cx| { + handle.update(cx, |this, cx| this.open_ai_settings(cx)).ok(); + }, + cx, + ); + return; + } + None => {} + } + + // Describe the checkout the commit will land in. Reading the main + // repository here made "generate message" summarise the main checkout's + // staged changes while the commit went to the worktree. + let repo_path = workspace.effective_worktree_path(cx); + let summary = project.read(cx).staged_summary_at(&repo_path); + let ai_entity = ai.clone(); + let diff_repo_path = repo_path.clone(); + + if let Some(style) = style_override { + // A regenerate-in-this-style is a one-request override, so it is + // written through to settings and left there: the user asked for that + // style, and silently reverting it next time would be surprising. + cx.update_global::(|state, _cx| { + state.settings_mut().ai.commit_style = style.id().to_string(); + if let Err(error) = state.save() { + log::warn!("Failed to persist the commit style override: {}", error); + } + }); + } + + cx.spawn(async move |_, cx: &mut gpui::AsyncApp| { + let diff_text = cx + .background_executor() + .spawn(async move { + rgitui_git::compute_staged_diff_text(&diff_repo_path).unwrap_or_default() + }) + .await; + cx.update(|cx| { + ai_entity.update(cx, |generator, cx| { + // The generator owns the in-flight and cooldown guards and + // returns `None` when it refuses, emitting its own event; the + // panel's spinner is driven by `GenerationStarted`, so nothing + // here needs to pre-set it. + generator.generate_commit_message_with_tools( + diff_text, summary, repo_path, use_tools, cx, + ); + }); + }); + }) + .detach(); +} + pub(super) fn subscribe_toolbar( cx: &mut Context, project: &Entity, diff --git a/crates/rgitui_workspace/src/workspace/mod.rs b/crates/rgitui_workspace/src/workspace/mod.rs index a10e3dff..69f82500 100644 --- a/crates/rgitui_workspace/src/workspace/mod.rs +++ b/crates/rgitui_workspace/src/workspace/mod.rs @@ -506,6 +506,22 @@ impl Workspace { self._settings_window_closed_subscription = Some(subscription); } + /// Open (or focus) the settings window with the AI page already showing. + /// + /// Everything that reports an AI misconfiguration routes here, so the fix + /// is always one click from the complaint rather than a dead end. + pub(crate) fn open_ai_settings(&mut self, cx: &mut Context) { + self.open_or_focus_settings(cx); + let Some(handle) = self.settings_window else { + return; + }; + let _ = handle.update(cx, |settings_window, _window, cx| { + settings_window + .view() + .update(cx, |view, cx| view.show_ai_section(cx)); + }); + } + /// Set a status bar message that auto-clears after 5 seconds. pub(super) fn set_status_message(&mut self, msg: impl Into, cx: &mut Context) { self.status_message = Some(msg.into()); @@ -571,6 +587,10 @@ impl Workspace { cx.notify(); } crate::SettingsWindowAction::SettingsChanged => { + // `ai_ready` comes from settings, so the palette and keymap + // predicates go stale the moment AI is toggled or a key is + // added. + self.update_command_context(cx); self.on_settings_changed(cx); } crate::SettingsWindowAction::Toast(kind, message) => { @@ -746,6 +766,25 @@ impl Workspace { .update(cx, |layer, cx| layer.show_toast(message.clone(), kind, cx)); } + /// Show a toast that carries a single action. + /// + /// An error that names the fix but does not offer it makes the user go + /// find it; `[Open Settings]` and `[Retry]` belong on the toast itself. + pub(super) fn show_toast_with_action( + &mut self, + text: impl Into, + kind: ToastKind, + action_label: impl Into, + on_action: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, + cx: &mut Context, + ) { + let message = text.into(); + let action_label = action_label.into(); + self.toast_layer.update(cx, |layer, cx| { + layer.show_toast_with_action(message, kind, action_label, on_action, cx) + }); + } + pub fn active_project(&self) -> Option<&Entity> { self.tabs.get(self.active_tab).map(|t| &t.project) } diff --git a/crates/rgitui_workspace/src/workspace/tabs.rs b/crates/rgitui_workspace/src/workspace/tabs.rs index 1ff8520d..e15cb062 100644 --- a/crates/rgitui_workspace/src/workspace/tabs.rs +++ b/crates/rgitui_workspace/src/workspace/tabs.rs @@ -258,6 +258,12 @@ impl Workspace { has_token, ) .with_multi_commit_selection(tab.graph.read(cx).selected_commit_count() > 1) + .with_ai_ready( + cx.try_global::() + .is_some_and(|settings| { + settings.settings().ai.enabled && settings.has_ai_api_key() + }), + ) } /// Update the command palette's context with fresh data from the active tab. diff --git a/crates/rgitui_workspace/src/worktree_dialog.rs b/crates/rgitui_workspace/src/worktree_dialog.rs index a6ff6a47..195d7e4c 100644 --- a/crates/rgitui_workspace/src/worktree_dialog.rs +++ b/crates/rgitui_workspace/src/worktree_dialog.rs @@ -65,6 +65,7 @@ impl WorktreeDialog { TextInputEvent::Submit => { this.try_create(cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(text) => { this.error_message = Self::validate_name(text); cx.notify(); @@ -79,6 +80,7 @@ impl WorktreeDialog { TextInputEvent::Submit => { this.try_create(cx); } + TextInputEvent::Blurred => {} TextInputEvent::Changed(text) => { this.error_message = Self::validate_path(text); cx.notify(); From 39797c03d22fb87bc2f86bc1c75c0730beb3f8b5 Mon Sep 17 00:00:00 2001 From: Noah Clarkson Date: Mon, 7 Sep 2026 21:00:28 +1200 Subject: [PATCH 2/7] fix(ai): address Codex review on the AI subsystem overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honour `base_url_override` when fetching a model catalogue. The `/models` request went to the official host unconditionally, so opening the AI settings sent a gateway-only key to OpenAI and then reported the provider as unreachable while generation was correctly reaching the gateway. The catalogue cache is keyed by the resolved endpoint too, so removing an override no longer leaves the gateway's models on screen, and changing one drops the stale catalogue and connection result for the family. Route AI generation events by the panel captured at dispatch rather than by looking a tab up through `effective_repo_path`. That path moves when the user enters or leaves an inspected worktree, so a generation that outlived that click matched no tab: the message was discarded and the originating panel spun forever. Return the panel to idle on cancellation instead of to `Failed`. Routing a cancel through `fail_ai_generation` left a red "AI failed — retry" control on screen after the user had done exactly what they intended. Parse bracketed IPv6 authorities before stripping the port, so `http://[::1]:11434/v1` — an IPv6-only Ollama — validates as loopback instead of being rejected as insecure. Co-Authored-By: Claude Opus 5 --- crates/rgitui_ai/src/catalog.rs | 114 +++++++++++------- crates/rgitui_ai/src/provider.rs | 73 ++++++++++- crates/rgitui_workspace/src/commit_panel.rs | 13 +- .../src/settings_window/ai_section.rs | 43 ++++++- .../rgitui_workspace/src/workspace/events.rs | 87 +++++++++---- crates/rgitui_workspace/src/workspace/mod.rs | 1 + .../rgitui_workspace/src/workspace/state.rs | 20 ++- 7 files changed, 276 insertions(+), 75 deletions(-) diff --git a/crates/rgitui_ai/src/catalog.rs b/crates/rgitui_ai/src/catalog.rs index ea6f3ca1..f285f628 100644 --- a/crates/rgitui_ai/src/catalog.rs +++ b/crates/rgitui_ai/src/catalog.rs @@ -203,24 +203,44 @@ pub fn now_unix() -> i64 { .unwrap_or(0) } -fn catalog_path(provider: AiProvider) -> PathBuf { - cache_dir() - .join("models") - .join(format!("{}.json", provider.id())) +/// Where a provider's catalogue is cached. +/// +/// A gateway serves a different model list from the provider's own host, so +/// an overridden base URL gets its own file: sharing one would leave the +/// gateway's models on screen after the override is removed. +fn catalog_path(provider: AiProvider, base_url_override: &str) -> PathBuf { + let file = match crate::provider::openai_compat_models_url(provider, base_url_override) { + Some(url) => format!("{}-{:016x}.json", provider.id(), stable_hash(&url)), + None => format!("{}.json", provider.id()), + }; + cache_dir().join("models").join(file) +} + +/// A filename-safe digest of a gateway URL. Only needs to separate one +/// endpoint's cache from another's, never to be cryptographic. +fn stable_hash(value: &str) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() } /// Read a provider's cached catalogue. A few KB of JSON — cheap enough to read /// synchronously when the picker opens. -pub fn read_cached(provider: AiProvider) -> Option { - let json = std::fs::read_to_string(catalog_path(provider)).ok()?; +pub fn read_cached(provider: AiProvider, base_url_override: &str) -> Option { + let json = std::fs::read_to_string(catalog_path(provider, base_url_override)).ok()?; let catalog: CachedCatalog = serde_json::from_str(&json).ok()?; (catalog.schema == CATALOG_SCHEMA).then_some(catalog) } /// Write a provider's catalogue, temp-file-then-rename so a crash mid-write /// cannot leave a truncated file that then fails to parse forever. -pub fn write_cached(provider: AiProvider, catalog: &CachedCatalog) -> Result<()> { - let path = catalog_path(provider); +pub fn write_cached( + provider: AiProvider, + base_url_override: &str, + catalog: &CachedCatalog, +) -> Result<()> { + let path = catalog_path(provider, base_url_override); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -259,8 +279,9 @@ pub async fn fetch_models( provider: AiProvider, client: &Arc, api_key: Option<&str>, + base_url_override: &str, ) -> Result> { - fetch_models_inner(provider, client, api_key, false).await + fetch_models_inner(provider, client, api_key, base_url_override, false).await } /// As [`fetch_models`], but asks OpenRouter for its entire catalogue rather @@ -269,14 +290,16 @@ pub async fn fetch_all_models( provider: AiProvider, client: &Arc, api_key: Option<&str>, + base_url_override: &str, ) -> Result> { - fetch_models_inner(provider, client, api_key, true).await + fetch_models_inner(provider, client, api_key, base_url_override, true).await } async fn fetch_models_inner( provider: AiProvider, client: &Arc, api_key: Option<&str>, + base_url_override: &str, load_all: bool, ) -> Result> { if catalog_needs_key(provider) && api_key.map(str::trim).unwrap_or("").is_empty() { @@ -286,33 +309,40 @@ async fn fetch_models_inner( ); } - let (url, mut builder) = match provider { - AiProvider::OpenRouter => { - let url = if load_all { - OPENROUTER_ALL_MODELS_URL - } else { - OPENROUTER_MODELS_URL - }; - (url.to_string(), Request::builder()) - } - AiProvider::Anthropic => ( - // The default limit is 20; without this the list renders silently - // truncated. - "https://api.anthropic.com/v1/models?limit=100".to_string(), - Request::builder().header("anthropic-version", crate::provider::ANTHROPIC_VERSION), - ), - AiProvider::Gemini => ( - "https://generativelanguage.googleapis.com/v1beta/models?pageSize=200".to_string(), - Request::builder(), - ), - AiProvider::OpenAi => ( - "https://api.openai.com/v1/models".to_string(), - Request::builder(), - ), - AiProvider::DeepSeek => ( - "https://api.deepseek.com/models".to_string(), - Request::builder(), - ), + // A gateway serves its own catalogue at `/models`. Sending the key + // to the official host instead would disclose a gateway-only credential + // and report a connection failure for a provider that generates fine. + let gateway_url = crate::provider::openai_compat_models_url(provider, base_url_override); + let (url, mut builder) = match gateway_url { + Some(url) => (url, Request::builder()), + None => match provider { + AiProvider::OpenRouter => { + let url = if load_all { + OPENROUTER_ALL_MODELS_URL + } else { + OPENROUTER_MODELS_URL + }; + (url.to_string(), Request::builder()) + } + AiProvider::Anthropic => ( + // The default limit is 20; without this the list renders silently + // truncated. + "https://api.anthropic.com/v1/models?limit=100".to_string(), + Request::builder().header("anthropic-version", crate::provider::ANTHROPIC_VERSION), + ), + AiProvider::Gemini => ( + "https://generativelanguage.googleapis.com/v1beta/models?pageSize=200".to_string(), + Request::builder(), + ), + AiProvider::OpenAi => ( + "https://api.openai.com/v1/models".to_string(), + Request::builder(), + ), + AiProvider::DeepSeek => ( + "https://api.deepseek.com/models".to_string(), + Request::builder(), + ), + }, }; if let Some(key) = api_key.map(str::trim).filter(|key| !key.is_empty()) { @@ -332,10 +362,12 @@ async fn fetch_models_inner( .body(AsyncBody::from(Vec::new())) .with_context(|| format!("Failed to build the {} model-list request", provider))?; - let mut response = client - .send(request) - .await - .with_context(|| format!("Couldn't reach {}", provider.default_host()))?; + let mut response = client.send(request).await.with_context(|| { + format!( + "Couldn't reach {}", + crate::provider::effective_host(provider, base_url_override) + ) + })?; let status = response.status(); let body = read_response_body(&mut response).await?; diff --git a/crates/rgitui_ai/src/provider.rs b/crates/rgitui_ai/src/provider.rs index 9fb563e2..20d913da 100644 --- a/crates/rgitui_ai/src/provider.rs +++ b/crates/rgitui_ai/src/provider.rs @@ -168,9 +168,22 @@ pub fn validate_base_url(value: &str) -> Result<(), BaseUrlError> { } } +/// The host of an authority, with any port removed. +/// +/// Cannot simply split at the first colon: `[::1]:11434` is a bracketed IPv6 +/// literal, and splitting that way yields `"["`, which then fails every +/// loopback comparison and rejects a valid local gateway. +fn host_without_port(authority: &str) -> &str { + match authority.strip_prefix('[') { + Some(rest) => rest.split_once(']').map_or(rest, |(inner, _)| inner), + None => authority + .split_once(':') + .map_or(authority, |(host, _)| host), + } +} + fn is_loopback_host(host: &str) -> bool { - let bare = host.split(':').next().unwrap_or(host); - matches!(bare, "localhost" | "127.0.0.1" | "[::1]" | "::1") + matches!(host_without_port(host), "localhost" | "127.0.0.1" | "::1") } /// The host a request will actually reach, for the settings warning that says @@ -185,6 +198,23 @@ pub fn effective_host(provider: AiProvider, base_url_override: &str) -> String { } } +/// The `/models` URL for an OpenAI-compatible provider pointed at a gateway, +/// or `None` when the provider's built-in catalogue URL applies. +/// +/// The catalogue used to be fetched from the official host unconditionally, +/// so opening the AI settings sent a gateway-only key to OpenAI and then +/// reported the provider as unreachable — while generation itself was +/// correctly reaching the gateway all along. +pub(crate) fn openai_compat_models_url( + provider: AiProvider, + base_url_override: &str, +) -> Option { + if !provider.is_openai_compatible() { + return None; + } + normalize_base_url(base_url_override).map(|base| format!("{base}/models")) +} + /// The Gemini `generateContent` URL. The key travels in a header, never here. pub(crate) fn gemini_endpoint(model: &str) -> String { format!("https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent") @@ -482,6 +512,19 @@ mod tests { ); } + /// `[::1]` is the address an IPv6-only Ollama binds to. Splitting the + /// authority at the first colon yields `"["`, which used to fail every + /// loopback comparison and reject a valid local gateway as insecure. + #[test] + fn a_bracketed_ipv6_loopback_counts_as_local() { + assert_eq!(validate_base_url("http://[::1]:11434/v1"), Ok(())); + assert_eq!(validate_base_url("http://[::1]/v1"), Ok(())); + assert_eq!( + validate_base_url("http://[2001:db8::1]:11434/v1"), + Err(BaseUrlError::InsecureScheme) + ); + } + #[test] fn a_query_string_or_fragment_is_rejected() { assert_eq!( @@ -507,6 +550,32 @@ mod tests { assert_eq!(validate_base_url("https://"), Err(BaseUrlError::NotAUrl)); } + /// The catalogue used to be fetched from the official host regardless of + /// the override, sending a gateway-only key to OpenAI and reporting a + /// working configuration as unreachable. + #[test] + fn the_model_list_follows_the_gateway_the_chat_endpoint_uses() { + assert_eq!( + openai_compat_models_url(AiProvider::OpenAi, "https://gw.example.com/v1"), + Some("https://gw.example.com/v1/models".to_string()) + ); + // A pasted chat endpoint is trimmed the same way the chat URL is. + assert_eq!( + openai_compat_models_url( + AiProvider::OpenRouter, + "https://gw.example.com/v1/chat/completions" + ), + Some("https://gw.example.com/v1/models".to_string()) + ); + // No override, or a provider that does not honour one, keeps the + // built-in catalogue URL. + assert_eq!(openai_compat_models_url(AiProvider::OpenAi, ""), None); + assert_eq!( + openai_compat_models_url(AiProvider::Gemini, "https://gw.example.com/v1"), + None + ); + } + #[test] fn effective_host_names_where_the_key_is_actually_sent() { assert_eq!(effective_host(AiProvider::OpenAi, ""), "api.openai.com"); diff --git a/crates/rgitui_workspace/src/commit_panel.rs b/crates/rgitui_workspace/src/commit_panel.rs index f892aa58..10198bab 100644 --- a/crates/rgitui_workspace/src/commit_panel.rs +++ b/crates/rgitui_workspace/src/commit_panel.rs @@ -352,13 +352,24 @@ impl CommitPanel { cx.notify(); } - /// The generation failed or was cancelled. + /// The generation failed. pub fn fail_ai_generation(&mut self, cx: &mut Context) { self.set_editors_read_only(false, cx); self.ai_state = AiState::Failed; cx.notify(); } + /// The user cancelled the generation. + /// + /// Returns to idle rather than to [`AiState::Failed`]: routing a cancel + /// through `fail_ai_generation` left a red "AI failed — retry" control on + /// screen after the user had done exactly what they intended. + pub fn cancel_ai_generation(&mut self, cx: &mut Context) { + self.set_editors_read_only(false, cx); + self.ai_state = AiState::Idle; + cx.notify(); + } + /// Restore the message the AI replaced. pub fn undo_ai_message(&mut self, cx: &mut Context) { let Some(previous) = self.ai_undo.take() else { diff --git a/crates/rgitui_workspace/src/settings_window/ai_section.rs b/crates/rgitui_workspace/src/settings_window/ai_section.rs index fa3c122c..9df64739 100644 --- a/crates/rgitui_workspace/src/settings_window/ai_section.rs +++ b/crates/rgitui_workspace/src/settings_window/ai_section.rs @@ -126,8 +126,33 @@ impl SettingsView { self.set_feedback(error.message(), true, cx); return; } + if value == self.ai_base_url_override { + return; + } self.ai_base_url_override = value; self.save_settings(cx); + + // A catalogue and a connection result describe the endpoint they came + // from, so retargeting the OpenAI-compatible family invalidates both. + for provider in AiProvider::ALL + .iter() + .copied() + .filter(|provider| provider.is_openai_compatible()) + { + self.ai_catalog.remove(&provider); + self.ai_catalog_source.remove(&provider); + self.ai_connection.remove(&provider); + self.ai_connection_error.remove(&provider); + self.ai_verified_at.remove(&provider); + } + self.ai_catalog_error = None; + if let Some(provider) = self + .expanded_ai_provider + .filter(|provider| provider.is_openai_compatible()) + { + self.load_ai_catalog(provider, cx); + } + cx.notify(); } /// The one honest answer the settings page can give about a key. @@ -154,11 +179,16 @@ impl SettingsView { cx.notify(); let client = cx.http_client(); + // Probe the endpoint generation will actually use. Testing the + // official host instead sent a gateway-only key to the provider and + // then called a working configuration broken. + let base_url = self.ai_base_url_override.clone(); self.ai_test_task = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { // The round-trip and the parse both run off the UI thread. let executor = cx.background_executor().clone(); - let probe = executor - .spawn(async move { catalog::fetch_models(provider, &client, Some(&key)).await }); + let probe = executor.spawn(async move { + catalog::fetch_models(provider, &client, Some(&key), &base_url).await + }); let timeout = cx.background_executor().timer(CONNECTION_TEST_TIMEOUT); let result = futures::future::select(Box::pin(probe), Box::pin(timeout)).await; @@ -197,8 +227,9 @@ impl SettingsView { /// Read `provider`'s cached catalogue and render it at once, revalidating /// in the background only when it is not fresh. pub(super) fn load_ai_catalog(&mut self, provider: AiProvider, cx: &mut Context) { + let base_url = self.ai_base_url_override.clone(); if let std::collections::btree_map::Entry::Vacant(e) = self.ai_catalog.entry(provider) { - let cached = catalog::read_cached(provider); + let cached = catalog::read_cached(provider, &base_url); let fresh = cached.as_ref().map(|cached| { catalog::freshness(cached.fetched_at, catalog::now_unix()) == catalog::CatalogFreshness::Fresh @@ -242,6 +273,7 @@ impl SettingsView { } let client = cx.http_client(); + let base_url = self.ai_base_url_override.clone(); let generation = self.ai_catalog_generation.wrapping_add(1); self.ai_catalog_generation = generation; self.ai_catalog_loading = true; @@ -253,13 +285,14 @@ impl SettingsView { let fetched = cx .background_executor() .spawn(async move { - let models = catalog::fetch_models(provider, &client, key.as_deref()).await?; + let models = + catalog::fetch_models(provider, &client, key.as_deref(), &base_url).await?; let envelope = catalog::CachedCatalog { schema: catalog::CATALOG_SCHEMA, fetched_at: catalog::now_unix(), models: models.clone(), }; - if let Err(error) = catalog::write_cached(provider, &envelope) { + if let Err(error) = catalog::write_cached(provider, &base_url, &envelope) { log::warn!("Failed to cache the {} model list: {}", provider, error); } anyhow::Ok(models) diff --git a/crates/rgitui_workspace/src/workspace/events.rs b/crates/rgitui_workspace/src/workspace/events.rs index d86f2953..a009ba2f 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -28,8 +28,8 @@ use crate::{ }; use super::{ - ActiveOperation, BottomPanelMode, OperationOutput, UndoAction, ViewCacheKey, ViewCaches, - Workspace, + ActiveOperation, AiGenerationTarget, BottomPanelMode, OperationOutput, UndoAction, + ViewCacheKey, ViewCaches, Workspace, }; pub(super) fn build_worktree_graph_infos( @@ -325,28 +325,40 @@ pub(super) fn subscribe_interactive_rebase( .detach(); } -/// The commit panel a generation was started for, found by the repo path the -/// request carried. +/// The commit panel a generation was started from. /// /// Routing by `active_tab` meant that switching tabs mid-generation wrote the -/// message describing repo `foo`'s staged diff into repo `bar`'s commit box, -/// and left `foo`'s spinner running forever. -fn commit_panel_for( - workspace: &Workspace, - id: &GenerationId, - cx: &gpui::App, -) -> Option> { +/// message describing repo `foo`'s staged diff into repo `bar`'s commit box. +/// Looking the panel up by repo path fixed that but replaced it with a subtler +/// one: `effective_repo_path` moves when the user enters or leaves an inspected +/// worktree, so a generation that outlived that click matched no tab at all. +/// The panel handle is therefore captured at dispatch, in +/// [`register_ai_target`], and matched by sequence. +fn commit_panel_for(workspace: &Workspace, id: &GenerationId) -> Option> { workspace - .tabs - .iter() - .find(|tab| tab.effective_repo_path(cx) == id.repo_path) - .map(|tab| tab.commit_panel.clone()) + .operations + .ai_target + .as_ref() + .filter(|target| target.sequence == id.sequence) + .and_then(|target| target.panel.upgrade()) +} + +/// Forget the captured panel once a generation reaches a terminal event. +fn clear_ai_target(workspace: &mut Workspace, id: &GenerationId) { + if workspace + .operations + .ai_target + .as_ref() + .is_some_and(|target| target.sequence == id.sequence) + { + workspace.operations.ai_target = None; + } } pub(super) fn subscribe_ai(cx: &mut Context, ai: &Entity) { cx.subscribe(ai, |this, ai, event: &AiEvent, cx| match event { AiEvent::GenerationStarted(id) => { - if let Some(panel) = commit_panel_for(this, id, cx) { + if let Some(panel) = commit_panel_for(this, id) { panel.update(cx, |cp, cx| cp.begin_ai_generation(cx)); } // The status bar alone. A toast here was a third signal for one @@ -358,14 +370,16 @@ pub(super) fn subscribe_ai(cx: &mut Context, ai: &Entity // Routed to the chip as well as the status bar: this text is the // real progress trace ("Reading diff.rs"), and it used to go only // to the least-watched surface in the app. - if let Some(panel) = commit_panel_for(this, id, cx) { + if let Some(panel) = commit_panel_for(this, id) { let description = description.clone(); panel.update(cx, |cp, cx| cp.set_ai_progress(Some(description), cx)); } this.set_status_message(format!("AI: {}", description), cx); } AiEvent::GenerationCompleted(id, message) => { - let Some(panel) = commit_panel_for(this, id, cx) else { + let panel = commit_panel_for(this, id); + clear_ai_target(this, id); + let Some(panel) = panel else { // The tab closed mid-flight. Nothing to write to, and nothing // to report. return; @@ -376,9 +390,10 @@ pub(super) fn subscribe_ai(cx: &mut Context, ai: &Entity } AiEvent::GenerationFailed(id, error) => { log::error!("AI generation failed: {}", error); - if let Some(panel) = commit_panel_for(this, id, cx) { + if let Some(panel) = commit_panel_for(this, id) { panel.update(cx, |cp, cx| cp.fail_ai_generation(cx)); } + clear_ai_target(this, id); this.set_status_message(error.clone(), cx); // Sticky, and carrying the fix: an AI failure is almost always a // key or a model choice, both of which live one click away. @@ -396,9 +411,12 @@ pub(super) fn subscribe_ai(cx: &mut Context, ai: &Entity ); } AiEvent::GenerationCancelled(id) => { - if let Some(panel) = commit_panel_for(this, id, cx) { - panel.update(cx, |cp, cx| cp.fail_ai_generation(cx)); + // Back to idle, not to failed: the user asked for this, and a red + // "AI failed — retry" control is the wrong answer to it. + if let Some(panel) = commit_panel_for(this, id) { + panel.update(cx, |cp, cx| cp.cancel_ai_generation(cx)); } + clear_ai_target(this, id); this.set_status_message("AI generation cancelled.", cx); } AiEvent::RateLimited { wait } => { @@ -2971,7 +2989,8 @@ pub(super) fn start_ai_generation( }); } - cx.spawn(async move |_, cx: &mut gpui::AsyncApp| { + let target_panel = commit_panel.downgrade(); + cx.spawn(async move |workspace, cx: &mut gpui::AsyncApp| { let diff_text = cx .background_executor() .spawn(async move { @@ -2979,20 +2998,40 @@ pub(super) fn start_ai_generation( }) .await; cx.update(|cx| { - ai_entity.update(cx, |generator, cx| { + let started = ai_entity.update(cx, |generator, cx| { // The generator owns the in-flight and cooldown guards and // returns `None` when it refuses, emitting its own event; the // panel's spinner is driven by `GenerationStarted`, so nothing // here needs to pre-set it. generator.generate_commit_message_with_tools( diff_text, summary, repo_path, use_tools, cx, - ); + ) }); + if let Some(id) = started { + // Emitted events are queued and delivered when this update + // cycle ends, so the subscription still sees this target when + // `GenerationStarted` arrives. + register_ai_target(&workspace, id.sequence, target_panel, cx); + } }); }) .detach(); } +/// Record which panel the generation just dispatched belongs to. +fn register_ai_target( + workspace: &gpui::WeakEntity, + sequence: u64, + panel: gpui::WeakEntity, + cx: &mut gpui::App, +) { + workspace + .update(cx, |workspace, _cx| { + workspace.operations.ai_target = Some(AiGenerationTarget { sequence, panel }); + }) + .ok(); +} + pub(super) fn subscribe_toolbar( cx: &mut Context, project: &Entity, diff --git a/crates/rgitui_workspace/src/workspace/mod.rs b/crates/rgitui_workspace/src/workspace/mod.rs index 69f82500..400d33b3 100644 --- a/crates/rgitui_workspace/src/workspace/mod.rs +++ b/crates/rgitui_workspace/src/workspace/mod.rs @@ -415,6 +415,7 @@ impl Workspace { last_operation_output: None, is_loading: false, loading_message: None, + ai_target: None, }, focus: FocusState { last_focused_panel: None, diff --git a/crates/rgitui_workspace/src/workspace/state.rs b/crates/rgitui_workspace/src/workspace/state.rs index c2d7e8ff..41b6584a 100644 --- a/crates/rgitui_workspace/src/workspace/state.rs +++ b/crates/rgitui_workspace/src/workspace/state.rs @@ -1,8 +1,8 @@ -use gpui::{Bounds, Entity, Pixels}; +use gpui::{Bounds, Entity, Pixels, WeakEntity}; use rgitui_git::GitOperationUpdate; use crate::{ - BranchDialog, CommandPalette, ConfirmDialog, CreatePrDialog, GlobalSearchView, + BranchDialog, CommandPalette, CommitPanel, ConfirmDialog, CreatePrDialog, GlobalSearchView, InteractiveRebase, RenameDialog, RepoCloneDialog, RepoOpener, ShortcutsHelp, StashBranchDialog, StashSaveDialog, TagDialog, ThemeEditorDialog, WorktreeDialog, }; @@ -43,6 +43,18 @@ pub(crate) struct OverlayState { pub theme_editor: Entity, } +/// The commit panel one AI generation belongs to, captured when the request is +/// dispatched. +/// +/// The panel used to be looked up by comparing each tab's `effective_repo_path` +/// against the id's, which is mutable inspection state: entering or leaving a +/// worktree mid-generation made every subsequent event match no tab, so the +/// result was dropped and the originating panel spun forever. +pub(crate) struct AiGenerationTarget { + pub sequence: u64, + pub panel: WeakEntity, +} + /// Git operation tracking state. pub(crate) struct OperationState { pub active_git_operation: Option, @@ -51,6 +63,10 @@ pub(crate) struct OperationState { pub last_operation_output: Option, pub is_loading: bool, pub loading_message: Option, + /// The panel the in-flight AI generation writes to. The generator refuses + /// to start a second generation while one is running, so one slot is all + /// there can ever be. + pub ai_target: Option, } /// Focus management state. From 4ac1eeaabe19be324a257b98840b9b36b66b6f3b Mon Sep 17 00:00:00 2001 From: Noah Clarkson Date: Mon, 7 Sep 2026 21:01:19 +1200 Subject: [PATCH 3/7] docs(ai): GenerationId's repo path is the checkout, not the route Co-Authored-By: Claude Opus 5 --- crates/rgitui_ai/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rgitui_ai/src/lib.rs b/crates/rgitui_ai/src/lib.rs index 0c97643b..dcc87a6e 100644 --- a/crates/rgitui_ai/src/lib.rs +++ b/crates/rgitui_ai/src/lib.rs @@ -58,8 +58,8 @@ use tools::execute_tool_within; pub struct GenerationId { /// Monotonic per-`AiGenerator`. The same guard `apply_refresh_data` uses. pub sequence: u64, - /// The checkout the request describes. Routing by this rather than by - /// `active_tab` is what keeps a message for repo `foo` out of repo `bar`. + /// The checkout the request describes: the root the prompt's diff, the + /// project context and every tool call are read from. pub repo_path: PathBuf, } From 63246b9c689f803ed57bb86dc1922af244cb5c87 Mon Sep 17 00:00:00 2001 From: Noah Clarkson Date: Mon, 7 Sep 2026 21:59:57 +1200 Subject: [PATCH 4/7] fix(ai): keep unvalidated base URLs out of settings, load catalogues per provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base URL editor mirrored every keystroke into the field `save_settings` persists, so a URL the UI had just rejected still reached `settings.json` on the next unrelated save — and from there the provider's API key reached the insecure endpoint. The draft now lives in the editor until Enter or blur, and only a validated value is copied out, which also makes the "unchanged" check in `commit_base_url_override` compare against the committed value rather than against itself, so Enter actually saves and invalidates. `normalize_base_url` now rejects anything `validate_base_url` would. It is the one choke point every consumer goes through, so a plain-http gateway cannot become a live endpoint by a route that skips the field — a hand-edited settings.json included. Catalogue loading is tracked per provider. One shared flag and one shared task slot meant expanding a second provider cancelled the first's fetch and then suppressed its own, leaving the newly opened row on its bundled list until the user pressed Refresh by hand; the refresh error is per provider too, so one row's failure no longer annotates another's picker. Co-Authored-By: Claude Opus 5 --- crates/rgitui_ai/src/provider.rs | 36 +++++++++++++++++++ .../src/settings_window/ai_section.rs | 28 ++++++++------- .../src/settings_window/view.rs | 34 ++++++++++-------- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/crates/rgitui_ai/src/provider.rs b/crates/rgitui_ai/src/provider.rs index 20d913da..dfae454d 100644 --- a/crates/rgitui_ai/src/provider.rs +++ b/crates/rgitui_ai/src/provider.rs @@ -94,7 +94,15 @@ pub(crate) fn openai_compat_endpoint( /// no trailing slash, and no trailing `/chat/completions` the user may have /// pasted from a curl example. Returns `None` for an empty field, which means /// "use the built-in URL". +/// +/// A value that would be rejected in Settings is treated the same way. This is +/// the one choke point every consumer goes through, so a plain-`http` gateway +/// cannot become a live endpoint by a route that skips the field's validation +/// — a hand-edited `settings.json` included. fn normalize_base_url(base_url_override: &str) -> Option { + if validate_base_url(base_url_override).is_err() { + return None; + } let trimmed = base_url_override.trim().trim_end_matches('/'); if trimmed.is_empty() { return None; @@ -576,6 +584,34 @@ mod tests { ); } + /// Nothing that Settings would refuse may reach a request by another + /// route: the field is not the only way a value lands in `settings.json`. + #[test] + fn an_invalid_override_falls_back_to_the_built_in_endpoint() { + for invalid in [ + "http://gateway.example.com/v1", + "gateway.example.com", + "ftp://gateway.example.com/v1", + "https://gateway.example.com/v1?key=abc", + ] { + assert_eq!( + openai_compat_endpoint(AiProvider::OpenAi, invalid, true).url, + "https://api.openai.com/v1/chat/completions", + "input {invalid}" + ); + assert_eq!( + openai_compat_models_url(AiProvider::OpenAi, invalid), + None, + "input {invalid}" + ); + assert_eq!( + effective_host(AiProvider::OpenAi, invalid), + "api.openai.com", + "input {invalid}" + ); + } + } + #[test] fn effective_host_names_where_the_key_is_actually_sent() { assert_eq!(effective_host(AiProvider::OpenAi, ""), "api.openai.com"); diff --git a/crates/rgitui_workspace/src/settings_window/ai_section.rs b/crates/rgitui_workspace/src/settings_window/ai_section.rs index 9df64739..03ef0995 100644 --- a/crates/rgitui_workspace/src/settings_window/ai_section.rs +++ b/crates/rgitui_workspace/src/settings_window/ai_section.rs @@ -141,11 +141,11 @@ impl SettingsView { { self.ai_catalog.remove(&provider); self.ai_catalog_source.remove(&provider); + self.ai_catalog_error.remove(&provider); self.ai_connection.remove(&provider); self.ai_connection_error.remove(&provider); self.ai_verified_at.remove(&provider); } - self.ai_catalog_error = None; if let Some(provider) = self .expanded_ai_provider .filter(|provider| provider.is_openai_compatible()) @@ -268,7 +268,7 @@ impl SettingsView { // yet, and the bundled list is already showing. return; } - if !force && self.ai_catalog_loading { + if !force && self.ai_catalog_in_flight.contains_key(&provider) { return; } @@ -276,10 +276,10 @@ impl SettingsView { let base_url = self.ai_base_url_override.clone(); let generation = self.ai_catalog_generation.wrapping_add(1); self.ai_catalog_generation = generation; - self.ai_catalog_loading = true; + self.ai_catalog_in_flight.insert(provider, generation); cx.notify(); - self.ai_catalog_task = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + let task = cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { // Send, parse and cache-write, all off the UI thread: the // unfiltered OpenRouter payload is around 700 KB. let fetched = cx @@ -302,23 +302,26 @@ impl SettingsView { this.update(cx, |this, cx| { // Drop a superseded result, the same guard `apply_refresh_data` // uses in `rgitui_git`. - if this.ai_catalog_generation != generation { + if this.ai_catalog_in_flight.get(&provider) != Some(&generation) { return; } - this.ai_catalog_loading = false; + this.ai_catalog_in_flight.remove(&provider); match fetched { Ok(models) => { - this.ai_catalog_error = None; + this.ai_catalog_error.remove(&provider); this.apply_ai_catalog(provider, models, CatalogSource::Live, cx); } Err(error) => { - this.ai_catalog_error = Some(error.to_string()); + this.ai_catalog_error.insert(provider, error.to_string()); } } cx.notify(); }) .ok(); - })); + }); + // Keyed by provider: a single slot dropped — and so cancelled — the + // request the previously expanded row was still waiting on. + self.ai_catalog_tasks.insert(provider, task); } fn apply_ai_catalog( @@ -350,7 +353,7 @@ impl SettingsView { .ai_catalog_source .get(&provider) .map(|source| catalog_source_label(*source)); - let status = self.ai_catalog_error.clone().map(|error| { + let status = self.ai_catalog_error.get(&provider).map(|error| { format!( "Couldn't refresh the model list — showing the last known {} models. {error}", models.len() @@ -963,6 +966,7 @@ impl SettingsView { PinnedModelStatus::Unverified | PinnedModelStatus::Known(_) => {} } + let refreshing = self.ai_catalog_in_flight.contains_key(&provider); column = column.child( div() .flex() @@ -982,7 +986,7 @@ impl SettingsView { .child( Button::new( ElementId::Name(format!("ai-model-refresh-{}", provider.id()).into()), - if self.ai_catalog_loading { + if refreshing { "Refreshing…" } else { "Refresh" @@ -992,7 +996,7 @@ impl SettingsView { .size(ButtonSize::Compact) .icon(IconName::Refresh) .color(Color::Muted) - .disabled(self.ai_catalog_loading) + .disabled(refreshing) .tab_index(tab_base + 5) .on_click(cx.listener( move |this, _: &ClickEvent, _, cx| { diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index 44f16eb9..df09fb02 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -378,12 +378,17 @@ pub struct SettingsView { /// The live model catalogue, per provider, with where it came from. pub(super) ai_catalog: BTreeMap>, pub(super) ai_catalog_source: BTreeMap, - pub(super) ai_catalog_loading: bool, - pub(super) ai_catalog_error: Option, - /// Drops superseded catalogue results, the same guard `apply_refresh_data` - /// uses in `rgitui_git`. + pub(super) ai_catalog_error: BTreeMap, + /// Monotonic id for the next catalogue request. Drops superseded results, + /// the same guard `apply_refresh_data` uses in `rgitui_git`. pub(super) ai_catalog_generation: u64, - pub(super) ai_catalog_task: Option>, + /// The in-flight request per provider, by that id; presence means the row + /// is refreshing. One shared flag and one shared task slot meant expanding + /// a second provider cancelled the first's fetch and then suppressed its + /// own, leaving the newly opened row on its bundled list until the user + /// pressed Refresh by hand. + pub(super) ai_catalog_in_flight: BTreeMap, + pub(super) ai_catalog_tasks: BTreeMap>, pub(super) ai_model_picker: Entity, pub(super) ai_model_picker_open: bool, /// Debounces the keychain write so it happens once per pause in typing @@ -580,12 +585,13 @@ impl SettingsView { }); cx.subscribe( &ai_base_url_editor, - |this: &mut Self, _, event: &TextInputEvent, cx| match event { - TextInputEvent::Changed(text) => { - this.ai_base_url_override = text.clone(); - cx.notify(); - } - TextInputEvent::Submit | TextInputEvent::Blurred => { + |this: &mut Self, _, event: &TextInputEvent, cx| { + // The draft lives in the editor until Enter or blur, and only a + // validated value is copied out. Mirroring every keystroke into + // the field that `save_settings` writes meant a URL the UI had + // just rejected still reached `settings.json` on the next + // unrelated save — and then the provider's API key. + if matches!(event, TextInputEvent::Submit | TextInputEvent::Blurred) { this.commit_base_url_override(cx); } }, @@ -943,10 +949,10 @@ impl SettingsView { ai_test_task: None, ai_catalog: BTreeMap::new(), ai_catalog_source: BTreeMap::new(), - ai_catalog_loading: false, - ai_catalog_error: None, + ai_catalog_error: BTreeMap::new(), ai_catalog_generation: 0, - ai_catalog_task: None, + ai_catalog_in_flight: BTreeMap::new(), + ai_catalog_tasks: BTreeMap::new(), ai_model_picker, ai_model_picker_open: false, pending_secret_save: None, From 4a76df0671d78a0014518aaedf7bd281ea604535 Mon Sep 17 00:00:00 2001 From: Noah Clarkson Date: Mon, 7 Sep 2026 22:41:12 +1200 Subject: [PATCH 5/7] fix(ai): support keyless gateways, and scope tests and model pins per provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom OpenAI-compatible endpoint may be a keyless local service — Ollama's `/v1` is the motivating case — but every guard demanded a stored credential, so the base URL the last round made reachable could only be used by inventing a dummy key. `requires_api_key` now answers that question in one place, the authorization header is omitted rather than sent empty, and the AI button, the command palette and the catalogue fetch all gate on `ai_credentials_ready`. Connection tests are tracked per provider. A single task slot dropped — and so cancelled — the test another row was still showing as in progress, leaving it on "Testing" with its button disabled until the window was reopened. `select_ai_model` takes the provider it is pinning for. A suggestion accepted from an expanded but inactive row went through `set_active_model`, which pinned that row's model to whichever provider happened to be active and left the row's own retired pin in place — so the next generation ran against a model the active provider does not have. Co-Authored-By: Claude Opus 5 --- crates/rgitui_ai/src/catalog.rs | 18 +++++--- crates/rgitui_ai/src/lib.rs | 40 +++++++++++------- crates/rgitui_ai/src/provider.rs | 42 +++++++++++++++++++ crates/rgitui_workspace/src/commit_panel.rs | 4 +- .../src/settings_window/ai_section.rs | 41 ++++++++++++++---- .../src/settings_window/view.rs | 9 ++-- .../rgitui_workspace/src/workspace/events.rs | 2 +- crates/rgitui_workspace/src/workspace/tabs.rs | 6 ++- 8 files changed, 127 insertions(+), 35 deletions(-) diff --git a/crates/rgitui_ai/src/catalog.rs b/crates/rgitui_ai/src/catalog.rs index f285f628..49aec146 100644 --- a/crates/rgitui_ai/src/catalog.rs +++ b/crates/rgitui_ai/src/catalog.rs @@ -259,9 +259,10 @@ pub fn write_cached( // ============================================================================ /// Whether fetching this provider's catalogue requires the user's API key. -/// Only OpenRouter's is public. -pub fn catalog_needs_key(provider: AiProvider) -> bool { +/// Only OpenRouter's is public — and a custom endpoint may be keyless. +pub fn catalog_needs_key(provider: AiProvider, base_url_override: &str) -> bool { !matches!(provider, AiProvider::OpenRouter) + && crate::provider::requires_api_key(provider, base_url_override) } /// The recommended OpenRouter query: tool-capable text models sorted by coding @@ -302,7 +303,9 @@ async fn fetch_models_inner( base_url_override: &str, load_all: bool, ) -> Result> { - if catalog_needs_key(provider) && api_key.map(str::trim).unwrap_or("").is_empty() { + if catalog_needs_key(provider, base_url_override) + && api_key.map(str::trim).unwrap_or("").is_empty() + { anyhow::bail!( "{} needs an API key before its model list can be loaded.", provider.display_name() @@ -1197,14 +1200,19 @@ mod tests { #[test] fn only_openrouters_catalogue_is_public() { - assert!(!catalog_needs_key(AiProvider::OpenRouter)); + assert!(!catalog_needs_key(AiProvider::OpenRouter, "")); + // A keyless local gateway serves its own list without a credential. + assert!(!catalog_needs_key( + AiProvider::OpenAi, + "http://localhost:11434/v1" + )); for provider in [ AiProvider::Gemini, AiProvider::OpenAi, AiProvider::Anthropic, AiProvider::DeepSeek, ] { - assert!(catalog_needs_key(provider)); + assert!(catalog_needs_key(provider, "")); } } diff --git a/crates/rgitui_ai/src/lib.rs b/crates/rgitui_ai/src/lib.rs index dcc87a6e..43de8546 100644 --- a/crates/rgitui_ai/src/lib.rs +++ b/crates/rgitui_ai/src/lib.rs @@ -36,7 +36,10 @@ use std::sync::Arc; use std::time::{Duration, Instant}; pub use prompt::CommitStyle; -pub use provider::{effective_host, validate_base_url, BaseUrlError}; +pub use provider::{ + ai_credentials_ready, effective_host, requires_api_key, uses_custom_endpoint, + validate_base_url, BaseUrlError, +}; pub use tools::{ anthropic_tool_definitions, denied_path, execute_tool, gemini_tool_definitions, openai_tool_definitions, DeniedReason, ToolBudget, ToolCall, ToolResult, @@ -364,17 +367,21 @@ async fn dispatch(req: &GenerateRequest, status: &StatusSender) -> Result Result<&str> { - req.api_key +/// The key to present, or `None` when the request targets a keyless custom +/// endpoint. Only a configuration that genuinely needs a credential fails here. +fn api_key(req: &GenerateRequest) -> Result> { + let key = req + .api_key .as_deref() .map(str::trim) - .filter(|key| !key.is_empty()) - .with_context(|| { - format!( - "No {} API key. Add one in Settings > AI.", - req.provider.display_name() - ) - }) + .filter(|key| !key.is_empty()); + if key.is_none() && requires_api_key(req.provider, &req.base_url_override) { + anyhow::bail!( + "No {} API key. Add one in Settings > AI.", + req.provider.display_name() + ); + } + Ok(key) } /// Send one request, retrying rate limits and transient server errors with @@ -400,14 +407,19 @@ async fn send_json( // then stalls leaves the spinner running until the app restarts. .timeout(REQUEST_TIMEOUT); - builder = match auth_style(req.provider) { - AuthStyle::Bearer => builder.header("Authorization", format!("Bearer {key}")), - AuthStyle::AnthropicHeader => builder + builder = match (auth_style(req.provider), key) { + // A keyless gateway gets no authorization header at all rather + // than an empty one, which some servers reject outright. + (_, None) => builder, + (AuthStyle::Bearer, Some(key)) => { + builder.header("Authorization", format!("Bearer {key}")) + } + (AuthStyle::AnthropicHeader, Some(key)) => builder .header("x-api-key", key) .header("anthropic-version", ANTHROPIC_VERSION), // A header, not `?key=`: query strings land in proxy logs, // TLS-inspecting middleboxes, and any error path that prints a URL. - AuthStyle::GoogleHeader => builder.header("x-goog-api-key", key), + (AuthStyle::GoogleHeader, Some(key)) => builder.header("x-goog-api-key", key), }; for (name, value) in extra_headers { builder = builder.header(*name, *value); diff --git a/crates/rgitui_ai/src/provider.rs b/crates/rgitui_ai/src/provider.rs index dfae454d..ee352613 100644 --- a/crates/rgitui_ai/src/provider.rs +++ b/crates/rgitui_ai/src/provider.rs @@ -206,6 +206,28 @@ pub fn effective_host(provider: AiProvider, base_url_override: &str) -> String { } } +/// Whether `base_url_override` actually redirects this provider's requests. +pub fn uses_custom_endpoint(provider: AiProvider, base_url_override: &str) -> bool { + provider.is_openai_compatible() && normalize_base_url(base_url_override).is_some() +} + +/// Whether a request can be sent at all without a stored API key. +/// +/// A custom endpoint may be a keyless local service — Ollama's `/v1` is the +/// motivating case — so demanding a credential there turns a configuration the +/// app supports into one reachable only by inventing a dummy key. +pub fn requires_api_key(provider: AiProvider, base_url_override: &str) -> bool { + !uses_custom_endpoint(provider, base_url_override) +} + +/// Whether the saved AI configuration has the credential it needs to run. +/// +/// The three entry points that gate the AI button share this so they cannot +/// disagree about whether a keyless gateway counts as configured. +pub fn ai_credentials_ready(settings: &rgitui_settings::AiSettings, has_stored_key: bool) -> bool { + has_stored_key || !requires_api_key(settings.provider, &settings.base_url_override) +} + /// The `/models` URL for an OpenAI-compatible provider pointed at a gateway, /// or `None` when the provider's built-in catalogue URL applies. /// @@ -612,6 +634,26 @@ mod tests { } } + #[test] + fn only_a_custom_endpoint_may_be_keyless() { + assert!(requires_api_key(AiProvider::OpenAi, "")); + assert!(!requires_api_key( + AiProvider::OpenAi, + "http://localhost:11434/v1" + )); + // Gemini and Anthropic never honour an override, so nothing about one + // can excuse them from needing a key. + assert!(requires_api_key( + AiProvider::Gemini, + "http://localhost:11434/v1" + )); + // Nor can a rejected override, which never becomes a live endpoint. + assert!(requires_api_key( + AiProvider::OpenAi, + "http://gateway.example.com/v1" + )); + } + #[test] fn effective_host_names_where_the_key_is_actually_sent() { assert_eq!(effective_host(AiProvider::OpenAi, ""), "api.openai.com"); diff --git a/crates/rgitui_workspace/src/commit_panel.rs b/crates/rgitui_workspace/src/commit_panel.rs index 10198bab..ece766d7 100644 --- a/crates/rgitui_workspace/src/commit_panel.rs +++ b/crates/rgitui_workspace/src/commit_panel.rs @@ -772,7 +772,9 @@ impl Render for CommitPanel { let settings = s.settings(); ( settings.ai.enabled, - s.has_ai_api_key(), + // A custom endpoint may be a keyless local service, so a + // stored key is not the only way to be configured. + rgitui_ai::ai_credentials_ready(&settings.ai, s.has_ai_api_key()), settings.ai.use_tools, ) }) diff --git a/crates/rgitui_workspace/src/settings_window/ai_section.rs b/crates/rgitui_workspace/src/settings_window/ai_section.rs index 03ef0995..0214313f 100644 --- a/crates/rgitui_workspace/src/settings_window/ai_section.rs +++ b/crates/rgitui_workspace/src/settings_window/ai_section.rs @@ -110,11 +110,30 @@ impl SettingsView { cx.notify(); } - pub(super) fn select_ai_model(&mut self, model: String, cx: &mut Context) { - self.ai_model = model.clone(); + /// Pin `model` to `provider`. + /// + /// The provider is explicit because a suggestion can be accepted from an + /// expanded row that is not the active one: writing it through + /// `set_active_model` pinned that row's model to whichever provider + /// happened to be active and left the row's own broken pin in place. + pub(super) fn select_ai_model( + &mut self, + provider: AiProvider, + model: String, + cx: &mut Context, + ) { self.ai_model_picker_open = false; + if provider == self.ai_provider { + self.ai_model = model.clone(); + } cx.update_global::(|state, _cx| { - state.settings_mut().ai.set_active_model(model); + let ai = &mut state.settings_mut().ai; + if provider == ai.provider { + ai.set_active_model(model); + } else { + ai.models_by_provider + .insert(provider.id().to_string(), model); + } }); self.save_settings(cx); cx.notify(); @@ -166,7 +185,7 @@ impl SettingsView { .get(&provider) .map(|editor| editor.read(cx).text().trim().to_string()) .unwrap_or_default(); - if key.is_empty() { + if key.is_empty() && rgitui_ai::requires_api_key(provider, &self.ai_base_url_override) { self.ai_connection .insert(provider, ConnectionState::Unconfigured); cx.notify(); @@ -183,11 +202,12 @@ impl SettingsView { // official host instead sent a gateway-only key to the provider and // then called a working configuration broken. let base_url = self.ai_base_url_override.clone(); - self.ai_test_task = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + let task = cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { // The round-trip and the parse both run off the UI thread. let executor = cx.background_executor().clone(); let probe = executor.spawn(async move { - catalog::fetch_models(provider, &client, Some(&key), &base_url).await + let key = Some(key.as_str()).filter(|key| !key.is_empty()); + catalog::fetch_models(provider, &client, key, &base_url).await }); let timeout = cx.background_executor().timer(CONNECTION_TEST_TIMEOUT); let result = futures::future::select(Box::pin(probe), Box::pin(timeout)).await; @@ -221,7 +241,10 @@ impl SettingsView { } }) .ok(); - })); + }); + // Keyed by provider: a single slot cancelled the test already running + // for another row, which then stayed on "Testing" forever. + self.ai_test_tasks.insert(provider, task); } /// Read `provider`'s cached catalogue and render it at once, revalidating @@ -263,7 +286,7 @@ impl SettingsView { cx: &mut Context, ) { let key = cx.read_global::(|state, _cx| state.ai_api_key_for(provider)); - if catalog::catalog_needs_key(provider) && key.is_none() { + if catalog::catalog_needs_key(provider, &self.ai_base_url_override) && key.is_none() { // Not an error: the user simply has not connected this provider // yet, and the bundled list is already showing. return; @@ -944,7 +967,7 @@ impl SettingsView { .on_click(cx.listener( move |this, _: &ClickEvent, _, cx| { cx.stop_propagation(); - this.select_ai_model(suggestion.clone(), cx); + this.select_ai_model(provider, suggestion.clone(), cx); }, )), ); diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index df09fb02..dd71f21a 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -373,7 +373,9 @@ pub struct SettingsView { pub(super) ai_connection: BTreeMap, pub(super) ai_connection_error: BTreeMap, pub(super) ai_verified_at: BTreeMap, - pub(super) ai_test_task: Option>, + /// The running connection test per provider. A single slot dropped — and + /// so cancelled — the test another row was still showing as in progress. + pub(super) ai_test_tasks: BTreeMap>, /// The live model catalogue, per provider, with where it came from. pub(super) ai_catalog: BTreeMap>, @@ -633,7 +635,8 @@ impl SettingsView { &ai_model_picker, |this: &mut Self, _, event: &rgitui_ui::PickerEvent, cx| match event { rgitui_ui::PickerEvent::Selected(id) => { - this.select_ai_model(id.to_string(), cx); + let provider = this.ai_provider; + this.select_ai_model(provider, id.to_string(), cx); } rgitui_ui::PickerEvent::Dismissed => { this.ai_model_picker_open = false; @@ -946,7 +949,7 @@ impl SettingsView { ai_connection: BTreeMap::new(), ai_connection_error: BTreeMap::new(), ai_verified_at: BTreeMap::new(), - ai_test_task: None, + ai_test_tasks: BTreeMap::new(), ai_catalog: BTreeMap::new(), ai_catalog_source: BTreeMap::new(), ai_catalog_error: BTreeMap::new(), diff --git a/crates/rgitui_workspace/src/workspace/events.rs b/crates/rgitui_workspace/src/workspace/events.rs index a009ba2f..fd2cbc93 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -2941,7 +2941,7 @@ pub(super) fn start_ai_generation( let settings = settings_state.settings(); let blocker = crate::commit_panel::ai_blocker( settings.ai.enabled, - settings_state.has_ai_api_key(), + rgitui_ai::ai_credentials_ready(&settings.ai, settings_state.has_ai_api_key()), commit_panel.read(cx).staged_count(), ); let use_tools = settings.ai.use_tools; diff --git a/crates/rgitui_workspace/src/workspace/tabs.rs b/crates/rgitui_workspace/src/workspace/tabs.rs index e15cb062..a8484559 100644 --- a/crates/rgitui_workspace/src/workspace/tabs.rs +++ b/crates/rgitui_workspace/src/workspace/tabs.rs @@ -260,8 +260,10 @@ impl Workspace { .with_multi_commit_selection(tab.graph.read(cx).selected_commit_count() > 1) .with_ai_ready( cx.try_global::() - .is_some_and(|settings| { - settings.settings().ai.enabled && settings.has_ai_api_key() + .is_some_and(|state| { + let settings = state.settings(); + settings.ai.enabled + && rgitui_ai::ai_credentials_ready(&settings.ai, state.has_ai_api_key()) }), ) } From df0df7e3f2ca5133fee6d0febc0a223e730c437b Mon Sep 17 00:00:00 2001 From: Noah Clarkson Date: Mon, 7 Sep 2026 22:56:59 +1200 Subject: [PATCH 6/7] fix(ai): reject URL userinfo, drop stale test results, keep style overrides local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `http://localhost:80@evil.example/v1` passed validation: everything before the `@` is userinfo, not the host, so reading `localhost` off the front let the loopback exemption wave through a plaintext request to a remote host carrying the API key and the staged diff. Userinfo is now rejected outright, with a message pointing at the API key field. Connection tests carry a generation id, and editing a key or committing a new base URL clears the provider's in-flight entry. A result describing the credentials or the server the user just replaced no longer reports the new configuration as verified. The settings row gates on whether a provider is usable, not on whether a key is stored, so a keyless gateway shows its model picker — the one place its local model ids can be selected — instead of onboarding. "Regenerate in a different style" is request-local again, as its own doc comment described. Writing it through to settings changed every later generation and raced an already-open Settings window holding the old value. Co-Authored-By: Claude Opus 5 --- crates/rgitui_ai/src/lib.rs | 10 +- crates/rgitui_ai/src/provider.rs | 33 +++++- .../src/settings_window/ai_section.rs | 105 ++++++++++++------ .../src/settings_window/view.rs | 14 ++- .../rgitui_workspace/src/workspace/events.rs | 21 ++-- 5 files changed, 134 insertions(+), 49 deletions(-) diff --git a/crates/rgitui_ai/src/lib.rs b/crates/rgitui_ai/src/lib.rs index 43de8546..2f5e6931 100644 --- a/crates/rgitui_ai/src/lib.rs +++ b/crates/rgitui_ai/src/lib.rs @@ -207,7 +207,7 @@ impl AiGenerator { repo_path: PathBuf, cx: &mut Context, ) -> Option { - self.generate_commit_message_with_tools(diff, summary, repo_path, false, cx) + self.generate_commit_message_with_tools(diff, summary, repo_path, false, None, cx) } /// Generate a commit message, optionally letting the model call tools. @@ -216,12 +216,17 @@ impl AiGenerator { /// request was refused — already generating, or inside the cooldown. The /// caller does not need to guard those cases itself, which is what keeps /// the button, Ctrl+G and the command palette from disagreeing. + /// `style_override` applies to this request only, leaving the saved + /// preference alone: "Regenerate in a different style" is a one-off, and + /// writing it through to settings both changed every later generation and + /// raced an already-open Settings window holding the previous value. pub fn generate_commit_message_with_tools( &mut self, diff: String, summary: String, repo_path: PathBuf, use_tools: bool, + style_override: Option, cx: &mut Context, ) -> Option { if self.is_generating() { @@ -240,7 +245,8 @@ impl AiGenerator { let provider = settings.ai.provider; let api_key = settings_state.ai_api_key(); let model = settings.ai.model.clone(); - let commit_style = CommitStyle::from_id(&settings.ai.commit_style).unwrap_or_default(); + let commit_style = style_override + .unwrap_or_else(|| CommitStyle::from_id(&settings.ai.commit_style).unwrap_or_default()); let inject_project_context = settings.ai.inject_project_context; let base_url_override = settings.ai.base_url_override.clone(); let openrouter_attribution = settings.ai.openrouter_attribution; diff --git a/crates/rgitui_ai/src/provider.rs b/crates/rgitui_ai/src/provider.rs index ee352613..4c497d30 100644 --- a/crates/rgitui_ai/src/provider.rs +++ b/crates/rgitui_ai/src/provider.rs @@ -125,6 +125,7 @@ pub enum BaseUrlError { NotAUrl, InsecureScheme, HasQueryOrFragment, + HasUserinfo, } impl BaseUrlError { @@ -139,6 +140,9 @@ impl BaseUrlError { BaseUrlError::HasQueryOrFragment => { "Remove the query string or #fragment — only the base path is used." } + BaseUrlError::HasUserinfo => { + "Remove the user:password@ part — credentials belong in the API key field." + } } } } @@ -161,7 +165,15 @@ pub fn validate_base_url(value: &str) -> Result<(), BaseUrlError> { Some(parts) => parts, None => return Err(BaseUrlError::NotAUrl), }; - let host = rest.split('/').next().unwrap_or(""); + let authority = rest.split('/').next().unwrap_or(""); + // `http://localhost:80@evil.example/v1` is a remote URL: everything before + // the `@` is userinfo, not the host. Accepting it would have let the + // loopback exemption below wave through a plaintext request to + // `evil.example` carrying the API key and the staged diff. + if authority.contains('@') { + return Err(BaseUrlError::HasUserinfo); + } + let host = authority; if host.is_empty() || host.starts_with(':') { return Err(BaseUrlError::NotAUrl); } @@ -555,6 +567,25 @@ mod tests { ); } + /// The authority before an `@` is userinfo, so the real host is whatever + /// follows it. Reading `localhost` out of the front of one would have let + /// the loopback exemption send an API key to a remote host in plaintext. + #[test] + fn userinfo_cannot_disguise_a_remote_host_as_loopback() { + assert_eq!( + validate_base_url("http://localhost:80@evil.example/v1"), + Err(BaseUrlError::HasUserinfo) + ); + assert_eq!( + validate_base_url("https://user:pass@gw.example.com/v1"), + Err(BaseUrlError::HasUserinfo) + ); + assert_eq!( + effective_host(AiProvider::OpenAi, "http://localhost:80@evil.example/v1"), + "api.openai.com" + ); + } + #[test] fn a_query_string_or_fragment_is_rejected() { assert_eq!( diff --git a/crates/rgitui_workspace/src/settings_window/ai_section.rs b/crates/rgitui_workspace/src/settings_window/ai_section.rs index 0214313f..86f1c8cc 100644 --- a/crates/rgitui_workspace/src/settings_window/ai_section.rs +++ b/crates/rgitui_workspace/src/settings_window/ai_section.rs @@ -161,9 +161,7 @@ impl SettingsView { self.ai_catalog.remove(&provider); self.ai_catalog_source.remove(&provider); self.ai_catalog_error.remove(&provider); - self.ai_connection.remove(&provider); - self.ai_connection_error.remove(&provider); - self.ai_verified_at.remove(&provider); + self.invalidate_ai_connection(provider, cx); } if let Some(provider) = self .expanded_ai_provider @@ -174,6 +172,21 @@ impl SettingsView { cx.notify(); } + /// Forget what is known about `provider`'s connection, and any test still + /// in flight for it, because the credentials or the endpoint just changed. + pub(super) fn invalidate_ai_connection( + &mut self, + provider: AiProvider, + cx: &mut Context, + ) { + let had_state = self.ai_connection.remove(&provider).is_some(); + self.ai_connection_error.remove(&provider); + self.ai_verified_at.remove(&provider); + if self.ai_test_in_flight.remove(&provider).is_some() || had_state { + cx.notify(); + } + } + /// The one honest answer the settings page can give about a key. /// /// `has_api_key` was only `!trim().is_empty()`, so a typo was @@ -195,6 +208,9 @@ impl SettingsView { self.ai_connection .insert(provider, ConnectionState::Testing); self.ai_connection_error.remove(&provider); + let generation = self.ai_test_generation.wrapping_add(1); + self.ai_test_generation = generation; + self.ai_test_in_flight.insert(provider, generation); cx.notify(); let client = cx.http_client(); @@ -221,23 +237,32 @@ impl SettingsView { )), }; - this.update(cx, |this, cx| match outcome { - Ok(models) => { - this.ai_connection - .insert(provider, ConnectionState::Connected); - this.ai_connection_error.remove(&provider); - this.ai_verified_at - .insert(provider, std::time::Instant::now()); - // The test already fetched the catalogue; keep it rather - // than making a second identical request. - this.apply_ai_catalog(provider, models, CatalogSource::Live, cx); - cx.notify(); + this.update(cx, |this, cx| { + // The key or the endpoint may have been replaced while this + // was in flight; reporting the old result would mark the new + // configuration verified on evidence about a different one. + if this.ai_test_in_flight.get(&provider) != Some(&generation) { + return; } - Err(error) => { - this.ai_connection.insert(provider, ConnectionState::Failed); - this.ai_connection_error - .insert(provider, connection_error_message(provider, &error)); - cx.notify(); + this.ai_test_in_flight.remove(&provider); + match outcome { + Ok(models) => { + this.ai_connection + .insert(provider, ConnectionState::Connected); + this.ai_connection_error.remove(&provider); + this.ai_verified_at + .insert(provider, std::time::Instant::now()); + // The test already fetched the catalogue; keep it rather + // than making a second identical request. + this.apply_ai_catalog(provider, models, CatalogSource::Live, cx); + cx.notify(); + } + Err(error) => { + this.ai_connection.insert(provider, ConnectionState::Failed); + this.ai_connection_error + .insert(provider, connection_error_message(provider, &error)); + cx.notify(); + } } }) .ok(); @@ -516,9 +541,9 @@ impl SettingsView { if let Some(state) = self.ai_connection.get(&provider) { return *state; } - if self.provider_has_key(provider) { - // A stored key is "configured", never "verified" — the difference - // is the whole point of the Test button. + if self.provider_is_configured(provider) { + // Being configured is "configured", never "verified" — the + // difference is the whole point of the Test button. ConnectionState::Connected } else { ConnectionState::Unconfigured @@ -531,6 +556,15 @@ impl SettingsView { .is_some_and(|key| !key.trim().is_empty()) } + /// Whether this provider can be used as it stands: it holds a key, or it + /// is pointed at a custom endpoint that needs none. Gating the model row + /// on the key alone hid the picker for a keyless local gateway, whose + /// model ids are exactly the ones the built-in default does not have. + fn provider_is_configured(&self, provider: AiProvider) -> bool { + self.provider_has_key(provider) + || !rgitui_ai::requires_api_key(provider, &self.ai_base_url_override) + } + fn render_provider_accordion(&mut self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); let mut card = Self::setting_card(cx).gap(px(2.)); @@ -637,7 +671,7 @@ impl SettingsView { ) -> gpui::AnyElement { let colors = cx.colors().clone(); let state = self.connection_state(provider); - let has_key = self.provider_has_key(provider); + let configured = self.provider_is_configured(provider); let tab_base = SETTINGS_TAB_INDEX_BASE + 20 + (index as isize * 10); // `div().flex().flex_col()`, never `v_flex()`/`h_flex()` here: the @@ -654,9 +688,9 @@ impl SettingsView { .pr(px(6.)) .pb(px(6.)); - if !has_key { - // A provider with no key opens onto onboarding, not an empty text - // box. This is the first thing a new user sees. + if !configured { + // A provider with nothing configured opens onto onboarding, not an + // empty text box. This is the first thing a new user sees. body = body.child( div() .flex() @@ -686,7 +720,7 @@ impl SettingsView { // Status line under the field: what the app actually knows. let status_line: Option = match state { - ConnectionState::Connected if has_key => Some( + ConnectionState::Connected if self.provider_has_key(provider) => Some( match self.ai_verified_at.get(&provider) { Some(at) => format!( "Verified {} · stored in {}", @@ -700,6 +734,15 @@ impl SettingsView { } .into(), ), + // A keyless custom endpoint is configured without a stored key, + // so it needs its own line rather than the "add a key" default. + ConnectionState::Connected if configured => Some( + format!( + "No API key needed — requests go to {}.", + rgitui_ai::effective_host(provider, &self.ai_base_url_override) + ) + .into(), + ), ConnectionState::Testing => { Some(format!("Testing {}…", self.ai_model).to_string().into()) } @@ -725,11 +768,11 @@ impl SettingsView { })); } - if has_key { + if configured { body = body.child(self.render_model_row(provider, tab_base, cx)); } - body = body.child(self.render_provider_actions(provider, has_key, tab_base, cx)); + body = body.child(self.render_provider_actions(provider, configured, tab_base, cx)); if provider.is_openai_compatible() { body = body.child(self.render_advanced(provider, tab_base, cx)); @@ -1040,7 +1083,7 @@ impl SettingsView { fn render_provider_actions( &self, provider: AiProvider, - has_key: bool, + configured: bool, tab_base: isize, cx: &mut Context, ) -> gpui::AnyElement { @@ -1057,7 +1100,7 @@ impl SettingsView { .pt(px(4.)) .child(div().flex_1()); - if !has_key { + if !configured { // "Connect" is the user's goal; "Save" never was. return row .child( diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index dd71f21a..03ac8aff 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -376,6 +376,13 @@ pub struct SettingsView { /// The running connection test per provider. A single slot dropped — and /// so cancelled — the test another row was still showing as in progress. pub(super) ai_test_tasks: BTreeMap>, + /// Monotonic id for the next connection test. + pub(super) ai_test_generation: u64, + /// The in-flight test per provider, by that id. Editing the key or the + /// base URL clears the entry, so a result that describes the credentials + /// or the server the user just replaced is discarded rather than reported + /// as verifying the new configuration. + pub(super) ai_test_in_flight: BTreeMap, /// The live model catalogue, per provider, with where it came from. pub(super) ai_catalog: BTreeMap>, @@ -560,7 +567,10 @@ impl SettingsView { move |this: &mut Self, _, event: &TextInputEvent, cx| match event { // Debounced rather than per-keystroke, so the OS keychain // is not round-tripped for every character typed. - TextInputEvent::Changed(_) => this.schedule_secret_save(cx), + TextInputEvent::Changed(_) => { + this.invalidate_ai_connection(provider, cx); + this.schedule_secret_save(cx); + } // Enter and blur both flush: typing a key and clicking away // used to discard it silently, while pasting the same key // saved it immediately. @@ -950,6 +960,8 @@ impl SettingsView { ai_connection_error: BTreeMap::new(), ai_verified_at: BTreeMap::new(), ai_test_tasks: BTreeMap::new(), + ai_test_generation: 0, + ai_test_in_flight: BTreeMap::new(), ai_catalog: BTreeMap::new(), ai_catalog_source: BTreeMap::new(), ai_catalog_error: BTreeMap::new(), diff --git a/crates/rgitui_workspace/src/workspace/events.rs b/crates/rgitui_workspace/src/workspace/events.rs index fd2cbc93..4aa3c931 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -5,7 +5,7 @@ use std::sync::Mutex; use std::time::Instant; use futures::StreamExt; -use gpui::{AppContext, BorrowAppContext, Context, Entity, SharedString}; +use gpui::{AppContext, Context, Entity, SharedString}; use rgitui_ai::{AiEvent, AiGenerator, GenerationId}; use rgitui_diff::{ConflictResolution, DiffOperation, DiffSource, DiffViewer, DiffViewerEvent}; use rgitui_git::{ @@ -2977,18 +2977,6 @@ pub(super) fn start_ai_generation( let ai_entity = ai.clone(); let diff_repo_path = repo_path.clone(); - if let Some(style) = style_override { - // A regenerate-in-this-style is a one-request override, so it is - // written through to settings and left there: the user asked for that - // style, and silently reverting it next time would be surprising. - cx.update_global::(|state, _cx| { - state.settings_mut().ai.commit_style = style.id().to_string(); - if let Err(error) = state.save() { - log::warn!("Failed to persist the commit style override: {}", error); - } - }); - } - let target_panel = commit_panel.downgrade(); cx.spawn(async move |workspace, cx: &mut gpui::AsyncApp| { let diff_text = cx @@ -3004,7 +2992,12 @@ pub(super) fn start_ai_generation( // panel's spinner is driven by `GenerationStarted`, so nothing // here needs to pre-set it. generator.generate_commit_message_with_tools( - diff_text, summary, repo_path, use_tools, cx, + diff_text, + summary, + repo_path, + use_tools, + style_override, + cx, ) }); if let Some(id) = started { From 7f511c837cdcb73a020efa71f6b0a20257ab23a9 Mon Sep 17 00:00:00 2001 From: Noah Clarkson Date: Mon, 7 Sep 2026 23:14:23 +1200 Subject: [PATCH 7/7] fix(ai): confine project context to the repo, and load catalogues off the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project-context injection is on by default, so `collect_project_context` runs against whatever a freshly cloned repository contains. It followed symlinks: shipping `README.md` as a link to a credential file put that file in the prompt the first time the user generated a commit message. Each target is now canonicalised and required to stay beneath the canonical repository root, the same rule `get_file_content` already applies to paths the model asks for. The 50 KB budget was also applied only after every file had been read in full, so one huge `README.md` allocated its whole size before being thrown away. Each file is now read up to the remaining budget, header and marker included, so the total can no longer overshoot — which also fixes the tail truncation cutting a newline-free file back to nothing. The settings window read and deserialised the model cache on the UI thread; an unfiltered OpenRouter catalogue is a few hundred KB of JSON. The bundled list renders immediately and the cache read, freshness check, request and cache write all happen on the background executor behind one stale-result guard. Editing a provider's key now invalidates its catalogue as well as its connection state: `/models` results can be scoped to the credential, so the old key's list was being shown for the rest of the session, its cache stayed fresh for 24 hours, and an in-flight request could still install its result. Co-Authored-By: Claude Opus 5 --- crates/rgitui_ai/src/prompt.rs | 145 +++++++++++++-- .../src/settings_window/ai_section.rs | 167 +++++++++++++----- .../src/settings_window/view.rs | 10 +- 3 files changed, 255 insertions(+), 67 deletions(-) diff --git a/crates/rgitui_ai/src/prompt.rs b/crates/rgitui_ai/src/prompt.rs index 95d315fe..771c19d1 100644 --- a/crates/rgitui_ai/src/prompt.rs +++ b/crates/rgitui_ai/src/prompt.rs @@ -161,34 +161,89 @@ pub(crate) fn build_prompt( pub(crate) const PROJECT_CONTEXT_FILES: &[&str] = &["README.md", "CLAUDE.md", "AGENTS.md"]; pub(crate) const MAX_PROJECT_CONTEXT_BYTES: usize = 50_000; +/// Appended to a context file that did not fit in the remaining budget, so +/// the model is told the file is partial rather than reading a sentence that +/// stops mid-word. +const TRUNCATION_MARKER: &str = "\n[project context truncated]"; + /// Read the project-context files, if any exist. Blocking I/O — call it from a /// background task. +/// +/// Context injection is on by default, so this runs against whatever a freshly +/// cloned repository contains: every read is confined to the checkout and +/// bounded by the remaining budget before any bytes are taken. pub(crate) fn collect_project_context(repo_path: &Path) -> Option { + let canonical_repo = repo_path.canonicalize().ok()?; let mut combined = String::new(); for filename in PROJECT_CONTEXT_FILES { - let file_path = repo_path.join(filename); - if let Ok(contents) = std::fs::read_to_string(&file_path) { - if !contents.trim().is_empty() { - combined.push_str(&format!("=== {filename} ===\n{contents}\n\n")); - } + let header = format!("=== {filename} ===\n"); + // The header, the marker and the separator come out of the same + // budget, so a file that fills it cannot push the total over. + let overhead = header.len() + TRUNCATION_MARKER.len() + 2; + let Some(remaining) = MAX_PROJECT_CONTEXT_BYTES + .checked_sub(combined.len() + overhead) + .filter(|remaining| *remaining > 0) + else { + break; + }; + let Some((contents, truncated)) = read_context_file(&canonical_repo, filename, remaining) + else { + continue; + }; + if contents.trim().is_empty() { + continue; } + combined.push_str(&header); + combined.push_str(&contents); + if truncated { + combined.push_str(TRUNCATION_MARKER); + } + combined.push_str("\n\n"); } - if combined.is_empty() { - return None; - } + (!combined.is_empty()).then_some(combined) +} - if combined.len() > MAX_PROJECT_CONTEXT_BYTES { - let cut = { - let safe = safe_truncate(&combined, MAX_PROJECT_CONTEXT_BYTES); - safe.rfind('\n').unwrap_or(safe.len()) - }; - combined.truncate(cut); - combined.push_str("\n\n[project context truncated]"); +/// Read at most `limit` bytes of one project-context file, and only from +/// inside `canonical_repo`. Reports whether the file was cut short. +/// +/// The canonical check is what stops a cloned repository shipping `README.md` +/// as a symlink to a credential file and having the first generated commit +/// message upload it — the same rule `get_file_content` already applies to +/// paths the model asks for. The limit is applied while reading rather than +/// after, so a huge file cannot be pulled into memory in full only to be +/// truncated. +fn read_context_file( + canonical_repo: &Path, + filename: &str, + limit: usize, +) -> Option<(String, bool)> { + use std::io::Read as _; + + let canonical_file = canonical_repo.join(filename).canonicalize().ok()?; + if !canonical_file.starts_with(canonical_repo) || !canonical_file.is_file() { + return None; } - Some(combined) + let file = std::fs::File::open(&canonical_file).ok()?; + // A few bytes past the limit, so a character straddling the boundary still + // has all of its bytes present and the overshoot reveals a longer file. + let mut bytes = Vec::new(); + file.take(limit as u64 + 4).read_to_end(&mut bytes).ok()?; + let truncated = bytes.len() > limit; + + let text = match std::str::from_utf8(&bytes) { + Ok(text) => text, + // `error_len() == None` means the input ended mid-character, which is + // this function's own doing. Genuinely invalid bytes are rejected, as + // they are everywhere else the model is shown a file. + Err(error) if error.error_len().is_none() => { + std::str::from_utf8(&bytes[..error.valid_up_to()]).ok()? + } + Err(_) => return None, + }; + Some((safe_truncate(text, limit).to_string(), truncated)) } #[cfg(test)] @@ -349,7 +404,61 @@ mod tests { ) .unwrap(); let context = collect_project_context(dir.path()).unwrap(); - assert!(context.ends_with("[project context truncated]")); - assert!(context.len() < MAX_PROJECT_CONTEXT_BYTES + 100); + assert!(context.contains(TRUNCATION_MARKER)); + assert!(context.len() <= MAX_PROJECT_CONTEXT_BYTES); + } + + /// Context injection is on by default, so a cloned repository could ship + /// `README.md` as a symlink to a credential file and have the first + /// generated commit message upload it. + #[cfg(unix)] + #[test] + fn a_context_file_symlinked_outside_the_repo_is_not_read() { + let outside = TempDir::new().unwrap(); + let secret = outside.path().join("credentials"); + std::fs::write(&secret, "AWS_SECRET_ACCESS_KEY=hunter2").unwrap(); + + let dir = TempDir::new().unwrap(); + std::os::unix::fs::symlink(&secret, dir.path().join("README.md")).unwrap(); + std::fs::write(dir.path().join("CLAUDE.md"), "in-repo guidance").unwrap(); + + let context = collect_project_context(dir.path()).unwrap(); + assert!(!context.contains("hunter2")); + assert!(!context.contains("README.md")); + assert!(context.contains("in-repo guidance")); + } + + /// The budget used to be applied after every file had been read in full, + /// so one huge file allocated its whole size before being thrown away. + #[test] + fn no_single_context_file_is_read_beyond_the_budget() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("README.md"), + "x".repeat(MAX_PROJECT_CONTEXT_BYTES * 4), + ) + .unwrap(); + std::fs::write(dir.path().join("CLAUDE.md"), "y".repeat(1_000)).unwrap(); + + let context = collect_project_context(dir.path()).unwrap(); + assert!(context.len() <= MAX_PROJECT_CONTEXT_BYTES); + // The second file is skipped rather than read: the budget was already + // spent by the first. + assert!(!context.contains("CLAUDE.md")); + } + + /// A multi-byte character straddling the read boundary must not make the + /// whole file vanish. + #[test] + fn a_character_split_by_the_budget_does_not_discard_the_file() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("README.md"), + "é".repeat(MAX_PROJECT_CONTEXT_BYTES), + ) + .unwrap(); + let context = collect_project_context(dir.path()).unwrap(); + assert!(context.contains("README.md")); + assert!(context.contains('é')); } } diff --git a/crates/rgitui_workspace/src/settings_window/ai_section.rs b/crates/rgitui_workspace/src/settings_window/ai_section.rs index 86f1c8cc..7377f5e1 100644 --- a/crates/rgitui_workspace/src/settings_window/ai_section.rs +++ b/crates/rgitui_workspace/src/settings_window/ai_section.rs @@ -158,10 +158,7 @@ impl SettingsView { .copied() .filter(|provider| provider.is_openai_compatible()) { - self.ai_catalog.remove(&provider); - self.ai_catalog_source.remove(&provider); - self.ai_catalog_error.remove(&provider); - self.invalidate_ai_connection(provider, cx); + self.invalidate_ai_provider(provider, cx); } if let Some(provider) = self .expanded_ai_provider @@ -187,6 +184,23 @@ impl SettingsView { } } + /// As [`Self::invalidate_ai_connection`], and drop the model catalogue + /// with it. + /// + /// `/models` results can be scoped to the credential, so a list fetched + /// under the previous key describes an account the user is replacing. + /// Superseding the in-flight request also stops one that is already + /// running from installing its result afterwards. + pub(super) fn invalidate_ai_provider(&mut self, provider: AiProvider, cx: &mut Context) { + self.invalidate_ai_connection(provider, cx); + self.ai_catalog.remove(&provider); + self.ai_catalog_source.remove(&provider); + self.ai_catalog_error.remove(&provider); + self.ai_catalog_in_flight.remove(&provider); + self.ai_catalog_stale.insert(provider); + cx.notify(); + } + /// The one honest answer the settings page can give about a key. /// /// `has_api_key` was only `!trim().is_empty()`, so a typo was @@ -272,34 +286,35 @@ impl SettingsView { self.ai_test_tasks.insert(provider, task); } - /// Read `provider`'s cached catalogue and render it at once, revalidating - /// in the background only when it is not fresh. + /// Show `provider`'s bundled list at once, then resolve the real one. + /// + /// The cache used to be read and deserialised here, on the UI thread: an + /// unfiltered OpenRouter catalogue is a few hundred KB of JSON, so cold + /// storage stalled the settings window. Everything but the bundled list — + /// which is compiled in — now happens on the background executor. pub(super) fn load_ai_catalog(&mut self, provider: AiProvider, cx: &mut Context) { - let base_url = self.ai_base_url_override.clone(); - if let std::collections::btree_map::Entry::Vacant(e) = self.ai_catalog.entry(provider) { - let cached = catalog::read_cached(provider, &base_url); - let fresh = cached.as_ref().map(|cached| { - catalog::freshness(cached.fetched_at, catalog::now_unix()) - == catalog::CatalogFreshness::Fresh - }); - let (models, source) = catalog::resolve_catalog(provider, cached); - e.insert(models); - self.ai_catalog_source.insert(provider, source); - self.sync_model_picker(cx); - if fresh == Some(true) { - return; + match self.ai_catalog.entry(provider) { + std::collections::btree_map::Entry::Occupied(_) => { + if matches!( + self.ai_catalog_source.get(&provider), + Some(CatalogSource::Live) + ) { + return; + } + } + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(catalog::bundled_catalog(provider)); + self.ai_catalog_source + .insert(provider, CatalogSource::Bundled); + self.sync_model_picker(cx); } - } else if matches!( - self.ai_catalog_source.get(&provider), - Some(CatalogSource::Live) - ) { - return; } self.refresh_ai_catalog(provider, false, cx); } - /// Fetch `provider`'s catalogue in the background. + /// Resolve `provider`'s catalogue: cache first, network when the cache is + /// not fresh. /// /// A failure keeps the cached list on screen and reports itself inline. /// Blanking the picker because a refresh failed would be strictly worse @@ -316,6 +331,9 @@ impl SettingsView { // yet, and the bundled list is already showing. return; } + // A credential or endpoint change makes the cached list describe + // something other than the current configuration. + let force = force || self.ai_catalog_stale.remove(&provider); if !force && self.ai_catalog_in_flight.contains_key(&provider) { return; } @@ -328,23 +346,14 @@ impl SettingsView { cx.notify(); let task = cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { - // Send, parse and cache-write, all off the UI thread: the - // unfiltered OpenRouter payload is around 700 KB. - let fetched = cx + // Cache read, JSON parse, request, and cache write: all off the UI + // thread. An unfiltered OpenRouter catalogue is around 700 KB in + // either direction. + let outcome = cx .background_executor() - .spawn(async move { - let models = - catalog::fetch_models(provider, &client, key.as_deref(), &base_url).await?; - let envelope = catalog::CachedCatalog { - schema: catalog::CATALOG_SCHEMA, - fetched_at: catalog::now_unix(), - models: models.clone(), - }; - if let Err(error) = catalog::write_cached(provider, &base_url, &envelope) { - log::warn!("Failed to cache the {} model list: {}", provider, error); - } - anyhow::Ok(models) - }) + .spawn( + async move { resolve_catalog(provider, &client, key, &base_url, force).await }, + ) .await; this.update(cx, |this, cx| { @@ -354,15 +363,17 @@ impl SettingsView { return; } this.ai_catalog_in_flight.remove(&provider); - match fetched { - Ok(models) => { - this.ai_catalog_error.remove(&provider); - this.apply_ai_catalog(provider, models, CatalogSource::Live, cx); + match outcome.error { + Some(error) => { + this.ai_catalog_error.insert(provider, error); } - Err(error) => { - this.ai_catalog_error.insert(provider, error.to_string()); + None => { + this.ai_catalog_error.remove(&provider); } } + if let Some((models, source)) = outcome.models { + this.apply_ai_catalog(provider, models, source, cx); + } cx.notify(); }) .ok(); @@ -1401,6 +1412,68 @@ impl SettingsView { } /// A picker row for one model, with the facets its filter chips need. +/// What one catalogue resolution produced. +/// +/// The rows and the error are independent: a failed refresh still carries the +/// cached rows, because blanking a list because a request failed is strictly +/// worse than showing a stale one. +struct CatalogOutcome { + models: Option<(Vec, CatalogSource)>, + error: Option, +} + +/// Read the cache, and fetch only when it is not fresh. Pure I/O with no +/// entity access, so the whole thing runs on the background executor. +async fn resolve_catalog( + provider: AiProvider, + client: &std::sync::Arc, + key: Option, + base_url: &str, + force: bool, +) -> CatalogOutcome { + let cached = catalog::read_cached(provider, base_url); + let cached_rows = |cached: catalog::CachedCatalog| { + (!cached.models.is_empty()).then(|| { + let fetched_at = cached.fetched_at; + (cached.models, CatalogSource::Cache { fetched_at }) + }) + }; + + if !force { + let fresh = cached.as_ref().is_some_and(|cached| { + catalog::freshness(cached.fetched_at, catalog::now_unix()) + == catalog::CatalogFreshness::Fresh + }); + if fresh { + return CatalogOutcome { + models: cached.and_then(cached_rows), + error: None, + }; + } + } + + match catalog::fetch_models(provider, client, key.as_deref(), base_url).await { + Ok(models) => { + let envelope = catalog::CachedCatalog { + schema: catalog::CATALOG_SCHEMA, + fetched_at: catalog::now_unix(), + models: models.clone(), + }; + if let Err(error) = catalog::write_cached(provider, base_url, &envelope) { + log::warn!("Failed to cache the {} model list: {}", provider, error); + } + CatalogOutcome { + models: Some((models, CatalogSource::Live)), + error: None, + } + } + Err(error) => CatalogOutcome { + models: cached.and_then(cached_rows), + error: Some(error.to_string()), + }, + } +} + fn model_row(provider: AiProvider, model: &ModelInfo) -> PickerRow { let mut row = PickerRow::new(model.id.clone(), model.display_name.clone()) .secondary(provider.display_name()) diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index 03ac8aff..ac59ee6c 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -28,7 +28,7 @@ use rgitui_ui::{ Button, ButtonSize, ButtonStyle, CheckState, Checkbox, ConnectionState, Icon, IconName, IconSize, Label, LabelSize, Picker, TextInput, TextInputEvent, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use super::events::SettingsViewEvent; use super::{SettingsWindowAction, SettingsWindowActionGlobal}; @@ -398,6 +398,11 @@ pub struct SettingsView { /// pressed Refresh by hand. pub(super) ai_catalog_in_flight: BTreeMap, pub(super) ai_catalog_tasks: BTreeMap>, + /// Providers whose cached catalogue no longer describes their credentials, + /// so the next load must bypass the cache. `/models` results can be scoped + /// to the key, and a cache written under the old one stays fresh for 24 + /// hours. + pub(super) ai_catalog_stale: BTreeSet, pub(super) ai_model_picker: Entity, pub(super) ai_model_picker_open: bool, /// Debounces the keychain write so it happens once per pause in typing @@ -568,7 +573,7 @@ impl SettingsView { // Debounced rather than per-keystroke, so the OS keychain // is not round-tripped for every character typed. TextInputEvent::Changed(_) => { - this.invalidate_ai_connection(provider, cx); + this.invalidate_ai_provider(provider, cx); this.schedule_secret_save(cx); } // Enter and blur both flush: typing a key and clicking away @@ -968,6 +973,7 @@ impl SettingsView { ai_catalog_generation: 0, ai_catalog_in_flight: BTreeMap::new(), ai_catalog_tasks: BTreeMap::new(), + ai_catalog_stale: BTreeSet::new(), ai_model_picker, ai_model_picker_open: false, pending_secret_save: None,