diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 21e6ab829..590acd7c7 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -167,6 +167,12 @@ pub fn read_live_provider_settings(app: String) -> Result Result { + ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?; + Ok(true) +} + #[tauri::command] pub async fn test_api_endpoints( urls: Vec, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad7cb66b6..7e550fa04 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -848,6 +848,7 @@ pub fn run() { commands::get_common_config_snippet, commands::set_common_config_snippet, commands::read_live_provider_settings, + commands::patch_claude_live_settings, commands::get_settings, commands::save_settings, commands::get_rectifier_config, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 7f7d0c848..90e7c7861 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -284,6 +284,46 @@ pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Res } } +/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json. +/// Used for user-level preferences (attribution, thinking, etc.) that are +/// independent of the active provider. +pub fn patch_claude_live(patch: Value) -> Result<(), AppError> { + let path = get_claude_settings_path(); + let mut live = if path.exists() { + read_json_file(&path).unwrap_or_else(|_| json!({})) + } else { + json!({}) + }; + json_merge_patch(&mut live, &patch); + let settings = sanitize_claude_settings_for_live(&live); + write_json_file(&path, &settings)?; + Ok(()) +} + +/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites. +fn json_merge_patch(target: &mut Value, patch: &Value) { + if let Some(patch_obj) = patch.as_object() { + if !target.is_object() { + *target = json!({}); + } + let target_obj = target.as_object_mut().unwrap(); + for (key, value) in patch_obj { + if value.is_null() { + target_obj.remove(key); + } else if value.is_object() { + let entry = target_obj.entry(key.clone()).or_insert(json!({})); + json_merge_patch(entry, value); + // Clean up empty container objects + if entry.as_object().map_or(false, |o| o.is_empty()) { + target_obj.remove(key); + } + } else { + target_obj.insert(key.clone(), value.clone()); + } + } + } +} + /// Claude: merge only key env and top-level fields into live settings.json fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> { let path = get_claude_settings_path(); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 0b50bc0d5..98c8c7a38 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -587,6 +587,11 @@ impl ProviderService { read_live_settings(app_type) } + /// Patch Claude live settings directly (user-level preferences) + pub fn patch_claude_live(patch: Value) -> Result<(), AppError> { + live::patch_claude_live(patch) + } + /// Get custom endpoints list (re-export) pub fn get_custom_endpoints( state: &AppState, diff --git a/src/components/providers/forms/ClaudeQuickToggles.tsx b/src/components/providers/forms/ClaudeQuickToggles.tsx new file mode 100644 index 000000000..87a0d2423 --- /dev/null +++ b/src/components/providers/forms/ClaudeQuickToggles.tsx @@ -0,0 +1,134 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { invoke } from "@tauri-apps/api/core"; +import { Checkbox } from "@/components/ui/checkbox"; + +type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates"; + +interface ClaudeQuickTogglesProps { + /** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */ + onPatchApplied?: (patch: Record) => void; +} + +const defaultStates: Record = { + hideAttribution: false, + alwaysThinking: false, + enableTeammates: false, +}; + +function deriveStates( + cfg: Record, +): Record { + const env = cfg?.env as Record | undefined; + const attr = cfg?.attribution as Record | undefined; + return { + hideAttribution: attr?.commit === "" && attr?.pr === "", + alwaysThinking: cfg?.alwaysThinkingEnabled === true, + enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1", + }; +} + +/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */ +function jsonMergePatch( + target: Record, + patch: Record, +) { + for (const [key, value] of Object.entries(patch)) { + if (value === null || value === undefined) { + delete target[key]; + } else if (typeof value === "object" && !Array.isArray(value)) { + if ( + typeof target[key] !== "object" || + target[key] === null || + Array.isArray(target[key]) + ) { + target[key] = {}; + } + jsonMergePatch( + target[key] as Record, + value as Record, + ); + if (Object.keys(target[key] as Record).length === 0) { + delete target[key]; + } + } else { + target[key] = value; + } + } +} + +export { jsonMergePatch }; + +export function ClaudeQuickToggles({ + onPatchApplied, +}: ClaudeQuickTogglesProps) { + const { t } = useTranslation(); + const [states, setStates] = useState(defaultStates); + + const readLive = useCallback(async () => { + try { + const cfg = await invoke>( + "read_live_provider_settings", + { app: "claude" }, + ); + setStates(deriveStates(cfg)); + } catch { + // Live file missing or unreadable — show all unchecked + } + }, []); + + useEffect(() => { + readLive(); + }, [readLive]); + + const toggle = useCallback( + async (key: ToggleKey) => { + let patch: Record; + if (key === "hideAttribution") { + patch = states.hideAttribution + ? { attribution: null } + : { attribution: { commit: "", pr: "" } }; + } else if (key === "alwaysThinking") { + patch = states.alwaysThinking + ? { alwaysThinkingEnabled: null } + : { alwaysThinkingEnabled: true }; + } else { + patch = states.enableTeammates + ? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } } + : { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } }; + } + + // Optimistic update + setStates((prev) => ({ ...prev, [key]: !prev[key] })); + + try { + await invoke("patch_claude_live_settings", { patch }); + onPatchApplied?.(patch); + } catch { + // Revert on failure + readLive(); + } + }, + [states, readLive, onPatchApplied], + ); + + return ( +
+ {( + [ + ["hideAttribution", "claudeConfig.hideAttribution"], + ["alwaysThinking", "claudeConfig.alwaysThinking"], + ["enableTeammates", "claudeConfig.enableTeammates"], + ] as const + ).map(([key, i18nKey]) => ( + + ))} +
+ ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 06e81b398..585d38666 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -45,6 +45,7 @@ import { getCodexCustomTemplate } from "@/config/codexTemplates"; import CodexConfigEditor from "./CodexConfigEditor"; import GeminiConfigEditor from "./GeminiConfigEditor"; import JsonEditor from "@/components/JsonEditor"; +import { ClaudeQuickToggles, jsonMergePatch } from "./ClaudeQuickToggles"; import { Label } from "@/components/ui/label"; import { ProviderPresetSelector } from "./ProviderPresetSelector"; import { BasicFormFields } from "./BasicFormFields"; @@ -1432,8 +1433,24 @@ export function ProviderForm({ + { + try { + const cfg = JSON.parse( + form.getValues("settingsConfig") || "{}", + ); + jsonMergePatch(cfg, patch); + form.setValue( + "settingsConfig", + JSON.stringify(cfg, null, 2), + ); + } catch { + // invalid JSON in editor — skip mirror + } + }} + /> form.setValue("settingsConfig", value)} placeholder={`{ "env": {