diff --git a/Cargo.lock b/Cargo.lock index 662fa5e..c35812c 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 e44a287..9b79e5b 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 0000000..49aec14 --- /dev/null +++ b/crates/rgitui_ai/src/catalog.rs @@ -0,0 +1,1360 @@ +//! 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) +} + +/// 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, 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, + 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)?; + } + 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 — 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 +/// 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>, + base_url_override: &str, +) -> Result> { + fetch_models_inner(provider, client, api_key, base_url_override, 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>, + base_url_override: &str, +) -> Result> { + 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, 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() + ); + } + + // 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()) { + 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 {}", + crate::provider::effective_host(provider, base_url_override) + ) + })?; + + 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, "")); + // 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, "")); + } + } + + // ── 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 0000000..40390cd --- /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 0000000..c2ed4a3 --- /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 7566e7f..2f5e693 100644 --- a/crates/rgitui_ai/src/lib.rs +++ b/crates/rgitui_ai/src/lib.rs @@ -1,80 +1,129 @@ +//! 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::{ + ai_credentials_ready, effective_host, requires_api_key, uses_custom_endpoint, + 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: the root the prompt's diff, the + /// project context and every tool call are read from. + 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,1009 +137,774 @@ 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, None, 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. + /// `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, - ) -> 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 = 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; - 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 -} - -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) } -/// Generate a commit message using the Anthropic API. -async fn generate_anthropic(req: &GenerateRequest<'_>) -> Result { - let api_key = req +/// 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_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); + .as_deref() + .map(str::trim) + .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() + ); } - - 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(); - - Ok(text) + Ok(key) } -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." +/// 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), 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, Some(key)) => builder.header("x-goog-api-key", key), + }; + for (name, value) in extra_headers { + builder = builder.header(*name, *value); } - }; - - 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; + let request = builder + .body(AsyncBody::from(body_bytes.clone())) + .with_context(|| { + format!( + "Couldn't build the {} request.", + req.provider.display_name() + ) + })?; -fn collect_project_context(repo_path: &Path) -> Option { - let mut combined = String::new(); + let mut response = req.client.send(request).await.with_context(|| { + format!( + "Couldn't reach {}. Check your connection or proxy.", + provider_host(req) + ) + })?; - 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 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?; + + if status.is_success() { + return serde_json::from_slice(&raw).with_context(|| { + format!("Couldn't read {}'s response.", req.provider.display_name()) + }); } - } - if combined.is_empty() { - return None; - } + 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.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]"); + return Err(status_error(req, status.as_u16(), &raw)); } - 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; + } + + 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()); } - anyhow::bail!("Unexpected Gemini response format"); + // 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 +913,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 0000000..771c19d --- /dev/null +++ b/crates/rgitui_ai/src/prompt.rs @@ -0,0 +1,464 @@ +//! 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; + +/// 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 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"); + } + + (!combined.is_empty()).then_some(combined) +} + +/// 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; + } + + 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)] +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.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_ai/src/provider.rs b/crates/rgitui_ai/src/provider.rs new file mode 100644 index 0000000..4c497d3 --- /dev/null +++ b/crates/rgitui_ai/src/provider.rs @@ -0,0 +1,702 @@ +//! 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". +/// +/// 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; + } + 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, + HasUserinfo, +} + +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." + } + BaseUrlError::HasUserinfo => { + "Remove the user:password@ part — credentials belong in the API key field." + } + } + } +} + +/// 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 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); + } + + 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), + } +} + +/// 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 { + matches!(host_without_port(host), "localhost" | "127.0.0.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(), + } +} + +/// 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. +/// +/// 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") +} + +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) + ); + } + + /// `[::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) + ); + } + + /// 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!( + 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)); + } + + /// 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 + ); + } + + /// 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 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"); + 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 d82b911..1a2b4b1 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 fe1a467..41059b3 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 63ac30a..a164c3c 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 9a6e4d5..a3aad00 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 0000000..5a878f5 --- /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 cdd675a..a595345 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 0000000..a5d91a6 --- /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 0000000..916eaf2 --- /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 0000000..fa81587 --- /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 279e327..d4598e9 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 343e377..f443e8c 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 11539d3..b4cb38b 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 2c6462b..40b9dfd 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 173e961..ece766d 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,117 @@ 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. + 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 { + 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 +429,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 +713,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 +761,30 @@ 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(), + // 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, ) }) .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 +860,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 +1250,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 54396bc..c823a8e 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 b73261d..5c681c7 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 294f442..5cd920e 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 157ddd5..c3e7fd6 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 44d05cc..1407a91 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 4586199..2c6774d 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 0000000..7377f5e --- /dev/null +++ b/crates/rgitui_workspace/src/settings_window/ai_section.rs @@ -0,0 +1,1668 @@ +//! 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(); + } + + /// 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| { + 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(); + } + + 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; + } + 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.invalidate_ai_provider(provider, cx); + } + if let Some(provider) = self + .expanded_ai_provider + .filter(|provider| provider.is_openai_compatible()) + { + self.load_ai_catalog(provider, cx); + } + 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(); + } + } + + /// 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 + /// 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() && rgitui_ai::requires_api_key(provider, &self.ai_base_url_override) { + self.ai_connection + .insert(provider, ConnectionState::Unconfigured); + cx.notify(); + return; + } + + 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(); + // 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(); + 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 { + 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; + + 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| { + // 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; + } + 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(); + }); + // 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); + } + + /// 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) { + 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); + } + } + + self.refresh_ai_catalog(provider, false, cx); + } + + /// 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 + /// 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, &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; + } + // 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; + } + + 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_in_flight.insert(provider, generation); + cx.notify(); + + let task = cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + // 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 { resolve_catalog(provider, &client, key, &base_url, force).await }, + ) + .await; + + this.update(cx, |this, cx| { + // Drop a superseded result, the same guard `apply_refresh_data` + // uses in `rgitui_git`. + if this.ai_catalog_in_flight.get(&provider) != Some(&generation) { + return; + } + this.ai_catalog_in_flight.remove(&provider); + match outcome.error { + Some(error) => { + this.ai_catalog_error.insert(provider, error); + } + 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(); + }); + // 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( + &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.get(&provider).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_is_configured(provider) { + // Being configured 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()) + } + + /// 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.)); + + 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 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 + // 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 !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() + .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 self.provider_has_key(provider) => 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(), + ), + // 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()) + } + 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 configured { + body = body.child(self.render_model_row(provider, 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)); + } + + 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(provider, 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(_) => {} + } + + let refreshing = self.ai_catalog_in_flight.contains_key(&provider); + 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 refreshing { + "Refreshing…" + } else { + "Refresh" + }, + ) + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .icon(IconName::Refresh) + .color(Color::Muted) + .disabled(refreshing) + .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, + configured: 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 !configured { + // "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. +/// 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()) + .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 39a96b8..2627255 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 c776c33..ac59ee6 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, BTreeSet}; 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,74 @@ 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, + /// 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>, + pub(super) ai_catalog_source: BTreeMap, + 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, + /// 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>, + /// 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 + /// rather than once per keystroke. + pub(super) pending_secret_save: Option>, // Git state git_sign_commits: bool, @@ -272,7 +415,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 +430,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 +516,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 +546,126 @@ 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.invalidate_ai_provider(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. + 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| { + // 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); + } + }, + ) + .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) => { + let provider = this.ai_provider; + this.select_ai_model(provider, 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 +784,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 +823,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 +842,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 +861,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 +881,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 +906,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 +933,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 +947,41 @@ 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_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(), + 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, 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 +1000,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 +1022,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 +1067,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 +1088,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 +1106,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 +1194,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 +1253,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 +1274,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 +1335,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 +1372,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 +1607,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 +1644,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 +1655,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 +1917,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 +1934,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 +1957,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 +1972,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 +2171,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 +2189,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 +2433,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 +3644,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 +4207,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 +4253,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 +4296,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 +4334,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 1f96f65..7e57228 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 0ff9629..f962f08 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 d6bb242..498624f 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 e38bdd4..5da8152 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 675d75b..d49ca9c 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 7660306..0c90eec 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 9e23cf2..4aa3c93 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -6,7 +6,7 @@ use std::time::Instant; use futures::StreamExt; use gpui::{AppContext, Context, Entity, SharedString}; -use rgitui_ai::{AiEvent, AiGenerator}; +use rgitui_ai::{AiEvent, AiGenerator, GenerationId}; use rgitui_diff::{ConflictResolution, DiffOperation, DiffSource, DiffViewer, DiffViewerEvent}; use rgitui_git::{ CommitInfo, GitOperationKind, GitOperationState, GitProject, GitProjectEvent, @@ -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,34 +325,112 @@ pub(super) fn subscribe_interactive_rebase( .detach(); } +/// 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. +/// 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 + .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::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) { + 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) { + 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 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; + }; + 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) { + 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. + 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) => { + // 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 } => { + // 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 +2899,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 +2916,115 @@ 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, + 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; + + 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(); + + let target_panel = commit_panel.downgrade(); + cx.spawn(async move |workspace, 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| { + 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, + style_override, + 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 a10e3df..400d33b 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, @@ -506,6 +507,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 +588,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 +767,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/state.rs b/crates/rgitui_workspace/src/workspace/state.rs index c2d7e8f..41b6584 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. diff --git a/crates/rgitui_workspace/src/workspace/tabs.rs b/crates/rgitui_workspace/src/workspace/tabs.rs index 1ff8520..a848455 100644 --- a/crates/rgitui_workspace/src/workspace/tabs.rs +++ b/crates/rgitui_workspace/src/workspace/tabs.rs @@ -258,6 +258,14 @@ 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(|state| { + let settings = state.settings(); + settings.ai.enabled + && rgitui_ai::ai_credentials_ready(&settings.ai, state.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 a6ff6a4..195d7e4 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();