From 5104045ffb4d524431de2b86987f925024bb39cd Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 22 Feb 2026 19:37:49 +0800 Subject: [PATCH] feat(claude): add Quick Toggles for common Claude Code preferences Add checkbox toggles for hideAttribution, alwaysThinking, and enableTeammates that write directly to the live settings file via RFC 7396 JSON Merge Patch. Mirror changes to the form editor using form.watch for reactive updates. --- src-tauri/src/commands/provider.rs | 6 + src-tauri/src/lib.rs | 1 + src-tauri/src/services/provider/live.rs | 40 ++++++ src-tauri/src/services/provider/mod.rs | 5 + .../providers/forms/ClaudeQuickToggles.tsx | 134 ++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 19 ++- 6 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/components/providers/forms/ClaudeQuickToggles.tsx diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 21e6ab82..590acd7c 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 ad7cb66b..7e550fa0 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 7f7d0c84..90e7c786 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 0b50bc0d..98c8c7a3 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 00000000..87a0d242 --- /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 06e81b39..585d3866 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": {