diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bbc1b0f..bfeaff7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -71,7 +71,9 @@ "Bash(echo \"EXIT:$?\")", "Bash(ls target/)", "Bash(rustup target *)", - "Bash(echo \"exit code: $?\")" + "Bash(echo \"exit code: $?\")", + "Read(//home/cbr/.cargo/registry/src/**)", + "Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)" ] } } diff --git a/src-tauri/crates/commit_ai/src/cloud.rs b/src-tauri/crates/commit_ai/src/cloud.rs index 320640d..6656f0d 100644 --- a/src-tauri/crates/commit_ai/src/cloud.rs +++ b/src-tauri/crates/commit_ai/src/cloud.rs @@ -2,7 +2,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::{build_messages, sanitize_message}; +use crate::{build_messages, looks_like_diff_echo, sanitize_message}; // Generous sizing so a detailed body with bullet points isn't cut off. const DEFAULT_MAX_TOKENS: u32 = 1500; @@ -56,8 +56,14 @@ async fn openai_compatible_request( let body = OpenAiRequest { model: model.to_string(), messages: vec![ - OpenAiMessage { role: "system", content: system }, - OpenAiMessage { role: "user", content: user }, + OpenAiMessage { + role: "system", + content: system, + }, + OpenAiMessage { + role: "user", + content: user, + }, ], temperature: 0.3, }; @@ -82,17 +88,22 @@ async fn openai_compatible_request( return Err(format!("API error ({status}): {text}")); } - let parsed: OpenAiResponse = serde_json::from_str(&text) - .map_err(|err| format!("Could not process response: {err}"))?; + let parsed: OpenAiResponse = + serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?; - parsed + let message = parsed .choices .into_iter() .next() .and_then(|choice| choice.message.content) .map(|content| sanitize_message(&content)) .filter(|content| !content.is_empty()) - .ok_or_else(|| "The model did not return a response.".to_string()) + .ok_or_else(|| "The model did not return a response.".to_string())?; + + if looks_like_diff_echo(&message) { + return Err("The model returned the diff instead of a commit message.".to_string()); + } + Ok(message) } pub async fn generate_openai( @@ -168,7 +179,10 @@ pub async fn generate_anthropic( model: model.to_string(), max_tokens: DEFAULT_MAX_TOKENS, system, - messages: vec![AnthropicMessage { role: "user", content: user }], + messages: vec![AnthropicMessage { + role: "user", + content: user, + }], }; let client = http_client()?; @@ -190,14 +204,19 @@ pub async fn generate_anthropic( return Err(format!("API error ({status}): {text}")); } - let parsed: AnthropicResponse = serde_json::from_str(&text) - .map_err(|err| format!("Could not process response: {err}"))?; + let parsed: AnthropicResponse = + serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?; - parsed + let message = parsed .content .into_iter() .find_map(|block| block.text) .map(|text| sanitize_message(&text)) .filter(|text| !text.is_empty()) - .ok_or_else(|| "The model did not return a response.".to_string()) + .ok_or_else(|| "The model did not return a response.".to_string())?; + + if looks_like_diff_echo(&message) { + return Err("The model returned the diff instead of a commit message.".to_string()); + } + Ok(message) } diff --git a/src-tauri/crates/commit_ai/src/lib.rs b/src-tauri/crates/commit_ai/src/lib.rs index ec30de6..19a20ae 100644 --- a/src-tauri/crates/commit_ai/src/lib.rs +++ b/src-tauri/crates/commit_ai/src/lib.rs @@ -2,9 +2,13 @@ mod cloud; pub use cloud::{generate_anthropic, generate_custom, generate_openai}; -use std::sync::Arc; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + sync::Arc, +}; -use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages}; +use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole}; use tokio::sync::RwLock; /// One selectable local (on-device) model. Larger models produce better commit messages @@ -19,7 +23,7 @@ pub struct LocalModelOption { tokenizer_repo: &'static str, } -pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-1.5b"; +pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b"; pub const LOCAL_MODELS: &[LocalModelOption] = &[ LocalModelOption { @@ -52,6 +56,59 @@ fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> { LOCAL_MODELS.iter().find(|option| option.id == model_id) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LocalGenerationProfile { + Fast, + Balanced, + Detailed, +} + +impl Default for LocalGenerationProfile { + fn default() -> Self { + Self::Fast + } +} + +impl LocalGenerationProfile { + pub fn from_id(value: Option<&str>) -> Self { + match value + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "balanced" => Self::Balanced, + "detailed" => Self::Detailed, + _ => Self::Fast, + } + } + + pub fn diff_unified_context(self) -> &'static str { + match self { + Self::Fast => "--unified=1", + Self::Balanced => "--unified=2", + Self::Detailed => "--unified=3", + } + } + + fn max_diff_chars(self) -> usize { + match self { + Self::Fast => 8_000, + Self::Balanced => 12_000, + Self::Detailed => 24_000, + } + } + + fn max_output_tokens(self) -> usize { + match self { + Self::Fast => 160, + Self::Balanced => 360, + Self::Detailed => 750, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "lowercase")] pub enum CommitAiPhase { @@ -75,6 +132,20 @@ struct Inner { model_id: Option, error: Option, model: Option>, + cache: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GenerationCacheKey { + model_id: String, + profile: LocalGenerationProfile, + input_hash: u64, +} + +#[derive(Debug, Clone)] +struct GenerationCache { + key: GenerationCacheKey, + message: String, } /// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls @@ -92,6 +163,7 @@ impl Default for CommitAiEngine { model_id: None, error: None, model: None, + cache: None, })), } } @@ -129,6 +201,7 @@ impl CommitAiEngine { guard.phase = CommitAiPhase::Error; guard.model_id = Some(model_id.to_string()); guard.error = Some(format!("Unknown local model: {model_id}")); + guard.cache = None; return; }; @@ -138,6 +211,7 @@ impl CommitAiEngine { guard.model_id = Some(model_id.to_string()); guard.error = None; guard.model = None; + guard.cache = None; } let result = GgufModelBuilder::new(option.repo, vec![option.file]) @@ -157,10 +231,12 @@ impl CommitAiEngine { guard.model = Some(Arc::new(model)); guard.phase = CommitAiPhase::Ready; guard.error = None; + guard.cache = None; } Err(err) => { guard.phase = CommitAiPhase::Error; guard.error = Some(err.to_string()); + guard.cache = None; } } } @@ -169,22 +245,36 @@ impl CommitAiEngine { &self, diff: &str, notes: Option<&str>, + profile: LocalGenerationProfile, ) -> Result { - let model = { + let (model, cache_key) = { let guard = self.inner.read().await; match (guard.phase, &guard.model) { - (CommitAiPhase::Ready, Some(model)) => model.clone(), + (CommitAiPhase::Ready, Some(model)) => { + let cache_key = GenerationCacheKey { + model_id: guard.model_id.clone().unwrap_or_default(), + profile, + input_hash: generation_input_hash(diff, notes), + }; + if let Some(cache) = &guard.cache { + if cache.key == cache_key { + return Ok(cache.message.clone()); + } + } + (model.clone(), cache_key) + } _ => return Err("The local AI model is not ready yet.".to_string()), } }; - let (system, user) = build_messages(diff, notes)?; - let messages = TextMessages::new() + let (system, user) = build_local_messages(diff, notes, profile)?; + let request = RequestBuilder::new() + .set_sampler_max_len(profile.max_output_tokens()) .add_message(TextMessageRole::System, system) .add_message(TextMessageRole::User, user); let response = model - .send_chat_request(messages) + .send_chat_request(request) .await .map_err(|err| err.to_string())?; @@ -198,10 +288,29 @@ impl CommitAiEngine { if message.is_empty() { return Err("The model did not return a response.".to_string()); } + if looks_like_diff_echo(&message) { + return Err( + "The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(), + ); + } + { + let mut guard = self.inner.write().await; + guard.cache = Some(GenerationCache { + key: cache_key, + message: message.clone(), + }); + } Ok(message) } } +fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 { + let mut hasher = DefaultHasher::new(); + diff.hash(&mut hasher); + notes.unwrap_or("").hash(&mut hasher); + hasher.finish() +} + /// Models occasionally ignore the "no code fences" instruction (small local models /// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go /// straight into the commit-message box. @@ -221,6 +330,74 @@ pub(crate) fn sanitize_message(raw: &str) -> String { trimmed.to_string() } +/// Weak models (small local ones especially) sometimes just echo the prompt's diff +/// sections back instead of writing a commit message. Catch that so the UI can show a +/// clear error instead of dumping raw diff text into the commit-message box. +pub(crate) fn looks_like_diff_echo(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("diff --git") + || lower.contains("staged files:") + || lower.contains("staged changes:") + || lower.contains("diff stat:") + || lower.contains("detailed diff:") + || message.lines().any(|line| line.starts_with("@@ ")) +} + +fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String { + if input.len() <= max_chars { + return input.to_string(); + } + + let mut cut = max_chars; + while !input.is_char_boundary(cut) { + cut -= 1; + } + format!("{}\n\n[... diff truncated ...]", &input[..cut]) +} + +pub(crate) fn build_local_messages( + diff: &str, + notes: Option<&str>, + profile: LocalGenerationProfile, +) -> Result<(String, String), String> { + if diff.trim().is_empty() { + return Err("No staged changes available for a commit message.".to_string()); + } + + let diff = truncate_at_char_boundary(diff, profile.max_diff_chars()); + // Appended to every profile below: small local models occasionally just echo the input + // (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's + // `staged_diff_local`) instead of writing a new commit message. Naming those exact + // section headers here makes the failure mode explicit enough for weak models to avoid. + const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \ +do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \ +anywhere in your answer."; + let system = match profile { + LocalGenerationProfile::Fast => { + format!( + "You generate Git commit messages. Respond only with one Conventional Commits subject line: (): . Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}" + ) + } + LocalGenerationProfile::Balanced => { + format!( + "You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}" + ) + } + LocalGenerationProfile::Detailed => { + format!( + "You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}" + ) + } + }; + + let mut user = String::new(); + if let Some(n) = notes.filter(|n| !n.trim().is_empty()) { + user.push_str(&format!("Developer notes:\n{n}\n\n")); + } + user.push_str(&format!("Staged changes:\n{diff}")); + Ok((system, user)) +} + pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> { if diff.trim().is_empty() { return Err("No staged changes available for a commit message.".to_string()); @@ -228,26 +405,35 @@ pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, // Rough token estimate — small models often have an 8-32k context window. const MAX_CHARS: usize = 24_000; - let diff = if diff.len() > MAX_CHARS { - // Pull the byte index back to a valid UTF-8 char boundary, otherwise - // slicing mid-multi-byte-character would panic. - let mut cut = MAX_CHARS; - while !diff.is_char_boundary(cut) { - cut -= 1; - } - format!("{}\n\n[... diff truncated ...]", &diff[..cut]) - } else { - diff.to_string() - }; + let diff = truncate_at_char_boundary(diff, MAX_CHARS); - let system = "You are a tool that generates Git commit messages. \ -Respond only with the commit message in Conventional Commits format \ -((): ), followed by a body after a blank line. \ -Subject in imperative mood, max. 72 characters. \ -The body is required: summarize in a short paragraph what changed and why, \ -then list the key changes as bullet points (- ...), \ -grouped by affected area/file. Lines in the body max. 72 characters. \ -No preamble, no explanation, no code fences, answer in English" + let system = "You are a tool that writes a Git commit message describing a staged diff. \ +Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \ +do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \ +or 'Diff stat:' anywhere in your answer. \ +Format: a Conventional Commits header ((): ) in imperative mood, \ +max. 72 characters, then a blank line, then a body. \ +The body is required: a short, general paragraph (2-4 sentences) summarizing what changed \ +and why at a high level — do NOT enumerate every changed file individually. \ +You may optionally add up to 3 bullet points (- ...) afterward, but only for the most \ +significant changes overall, never one bullet or heading per file. \ +Never use bold text, backticks, or markdown headings for file names. \ +Lines in the body max. 72 characters. \ +No preamble, no explanation, no code fences, answer in English.\n\n\ +Example:\n\ +Diff:\n\ +diff --git a/src/auth.py b/src/auth.py\n\ ++def hash_password(pw):\n\ ++ return bcrypt.hash(pw)\n\ +diff --git a/src/routes.py b/src/routes.py\n\ +-if password == stored_password:\n\ ++if bcrypt.check(password, stored_password):\n\n\ +Commit message:\n\ +feat(auth): hash and verify passwords with bcrypt\n\n\ +Passwords were previously compared as plain text. This adds a bcrypt-based\n\ +hashing helper and updates the login check to verify against the hash\n\ +instead of a direct string comparison.\n\n\ +- Hash passwords on write, verify with bcrypt on login" .to_string(); let mut user = String::new(); diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index cfaed67..0ad8215 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -540,6 +540,50 @@ fn staged_diff(repo: &Path) -> Result { Ok(format!("Staged files:\n{file_list}\n\n{diff}")) } +fn staged_diff_local( + repo: &Path, + profile: commit_ai::LocalGenerationProfile, +) -> Result { + let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?; + let file_list = String::from_utf8_lossy(&name_status).trim().to_string(); + + let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?; + let stat = String::from_utf8_lossy(&stat).trim().to_string(); + + let diff_args = vec![ + "diff", + "--cached", + "--no-ext-diff", + "--no-textconv", + profile.diff_unified_context(), + "--", + ".", + ":(exclude)*package-lock.json", + ":(exclude)*pnpm-lock.yaml", + ":(exclude)*yarn.lock", + ":(exclude)*bun.lockb", + ":(exclude)*Cargo.lock", + ":(exclude)*composer.lock", + ":(exclude)*Gemfile.lock", + ":(exclude)*poetry.lock", + ":(exclude)*go.sum", + ]; + let diff = run_git(repo, diff_args)?; + let diff = String::from_utf8_lossy(&diff).trim().to_string(); + + let mut sections = Vec::new(); + if !file_list.is_empty() { + sections.push(format!("Staged files:\n{file_list}")); + } + if !stat.is_empty() { + sections.push(format!("Diff stat:\n{stat}")); + } + if !diff.is_empty() { + sections.push(format!("Detailed diff:\n{diff}")); + } + Ok(sections.join("\n\n")) +} + #[tauri::command] pub async fn commit_ai_generate( path: String, @@ -548,17 +592,27 @@ pub async fn commit_ai_generate( model: Option, api_key: Option, base_url: Option, + local_profile: Option, engine: tauri::State<'_, commit_ai::CommitAiEngine>, ) -> Result { let repo = resolve_repo(&path)?; - let diff = staged_diff(&repo)?; + let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref()); + let diff = if provider == "local" { + staged_diff_local(&repo, local_profile)? + } else { + staged_diff(&repo)? + }; let notes = notes.as_deref(); let model = model.filter(|value| !value.trim().is_empty()); let api_key = api_key.filter(|value| !value.trim().is_empty()); let base_url = base_url.filter(|value| !value.trim().is_empty()); match provider.as_str() { - "local" => engine.generate_commit_message(&diff, notes).await, + "local" => { + engine + .generate_commit_message(&diff, notes, local_profile) + .await + } "openai" => { let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?; let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string()); @@ -620,9 +674,7 @@ pub fn commit(path: String, message: String) -> Result { let current_status = status_for_repo(&repo)?; if has_unresolved_conflicts(¤t_status) { - return Err( - "Merge conflicts must be resolved before you can commit.".to_string(), - ); + return Err("Merge conflicts must be resolved before you can commit.".to_string()); } run_git(&repo, ["commit", "-m", message.as_str()])?; @@ -646,9 +698,7 @@ pub fn pull( .arg(&repo) .args(pull_args) .output() - .map_err(|err| { - format!("Could not start Git. Is Git installed? {err}") - })?, + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, }; if output.status.success() { @@ -703,8 +753,7 @@ fn cred_entry(key: &str) -> Result { if key.is_empty() { return Err("No key provided for the credentials.".to_string()); } - keyring::Entry::new(CRED_SERVICE, key) - .map_err(|err| format!("Keychain unavailable: {err}")) + keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}")) } /// Returns the remote URL used for auth key derivation (upstream remote of the @@ -790,9 +839,8 @@ fn initial_push_remote_name(repo: &Path) -> Result { return Ok("origin".to_string()); } - first_remote_name(repo).ok_or_else(|| { - "This branch has no upstream and no remote is configured.".to_string() - }) + first_remote_name(repo) + .ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string()) } fn push_args_for_repo(repo: &Path) -> Result, String> { @@ -2554,9 +2602,7 @@ fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result | undefined; @@ -322,6 +323,13 @@ startCommitAiPolling(); } + function updateCommitMessage(message: string) { + commitMessage = message; + if (message !== lastLocalAiGeneratedMessage) { + lastLocalAiGeneratedMessage = ""; + } + } + async function generateCommitMessageWithAi() { if (!activeRepoPath || commitAiGenerating) return; if (aiSettings.provider === "local" && commitAiPhase !== "ready") return; @@ -330,7 +338,13 @@ try { const notes = commitMessage.trim() || undefined; if (aiSettings.provider === "local") { - commitMessage = await commitAiGenerate(activeRepoPath, { provider: "local", notes }); + const localNotes = notes && notes !== lastLocalAiGeneratedMessage ? notes : undefined; + commitMessage = await commitAiGenerate(activeRepoPath, { + provider: "local", + notes: localNotes, + localProfile: aiSettings.localProfile, + }); + lastLocalAiGeneratedMessage = commitMessage; } else if (aiSettings.provider === "openai") { const cred = await credLoad("ai:openai"); commitMessage = await commitAiGenerate(activeRepoPath, { @@ -339,6 +353,7 @@ model: aiSettings.openaiModel, apiKey: cred?.password, }); + lastLocalAiGeneratedMessage = ""; } else if (aiSettings.provider === "anthropic") { const cred = await credLoad("ai:anthropic"); commitMessage = await commitAiGenerate(activeRepoPath, { @@ -347,6 +362,7 @@ model: aiSettings.anthropicModel, apiKey: cred?.password, }); + lastLocalAiGeneratedMessage = ""; } else { const cred = await credLoad("ai:custom"); commitMessage = await commitAiGenerate(activeRepoPath, { @@ -356,6 +372,7 @@ baseUrl: aiSettings.customBaseUrl, apiKey: cred?.password, }); + lastLocalAiGeneratedMessage = ""; } } catch (error) { errorMessage = errorToMessage(error); @@ -526,8 +543,9 @@ function defaultAiSettings(): AiSettings { return { - provider: "local", - localModelId: "qwen2.5-1.5b", + provider: "openai", + localModelId: "qwen2.5-0.5b", + localProfile: "fast", openaiModel: "gpt-4o-mini", anthropicModel: "claude-3-5-haiku-latest", customBaseUrl: "", @@ -539,7 +557,11 @@ try { const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown; if (stored && typeof stored === "object") { - return { ...defaultAiSettings(), ...(stored as Partial) }; + const merged = { ...defaultAiSettings(), ...(stored as Partial) }; + // Local AI is still in development and disabled in the settings UI — migrate any + // previously saved selection away from it so nobody gets stuck on a dead option. + if (merged.provider === "local") merged.provider = "openai"; + return merged; } } catch { // Fall through to defaults below. @@ -1339,6 +1361,7 @@ await runOperation("Committing", async () => { applyStatus(await commit(activeRepoPath, message)); commitMessage = ""; + lastLocalAiGeneratedMessage = ""; await refreshBranchList(activeRepoPath); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); @@ -1968,7 +1991,7 @@ {commitAiPhase} {commitAiGenerating} onCommit={commitChanges} - onCommitMessageChange={(msg) => { commitMessage = msg; }} + onCommitMessageChange={updateCommitMessage} onGenerateCommitMessage={generateCommitMessageWithAi} onOpenAiSettings={() => { aiSettingsOpen = true; }} /> diff --git a/src/app.css b/src/app.css index 4384c5c..334ce75 100644 --- a/src/app.css +++ b/src/app.css @@ -1721,6 +1721,44 @@ color: #f5f7ff; background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1)); } + .ai-provider-option-local { + flex-wrap: wrap; + row-gap: 2px; + } + .ai-provider-badge { + flex-basis: 100%; + text-align: center; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-ink-faint); + } + .ai-local-profile-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + .ai-local-profile-option { + min-width: 0; + min-height: 34px; + padding: 0 8px; + border-color: var(--color-border-subtle); + background: rgba(255,255,255,0.03); + color: var(--color-ink-dim); + font-size: 12px; + font-weight: 700; + } + .ai-local-profile-option:hover:not(:disabled) { + border-color: var(--color-border); + color: var(--color-ink); + background: var(--color-surface-hover); + } + .ai-local-profile-option.active { + border-color: rgba(65,209,255,0.48); + color: #f5f7ff; + background: linear-gradient(180deg, rgba(65,209,255,0.16), rgba(100,108,255,0.12)); + } .new-branch-form { display: flex; flex-direction: column; diff --git a/src/lib/components/AiSettingsDialog.svelte b/src/lib/components/AiSettingsDialog.svelte index 746c470..b74efc2 100644 --- a/src/lib/components/AiSettingsDialog.svelte +++ b/src/lib/components/AiSettingsDialog.svelte @@ -1,8 +1,8 @@ @@ -123,9 +141,16 @@
{ e.preventDefault(); void handleSave(); }}>
-
{#if provider === "local"} +
+ Local speed +
+ + + +
+