mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-06 21:08:30 +08:00
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.
This commit is contained in:
@@ -167,6 +167,12 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
|
||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
|
||||
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_api_endpoints(
|
||||
urls: Vec<String>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
134
src/components/providers/forms/ClaudeQuickToggles.tsx
Normal file
134
src/components/providers/forms/ClaudeQuickToggles.tsx
Normal file
@@ -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<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const defaultStates: Record<ToggleKey, boolean> = {
|
||||
hideAttribution: false,
|
||||
alwaysThinking: false,
|
||||
enableTeammates: false,
|
||||
};
|
||||
|
||||
function deriveStates(
|
||||
cfg: Record<string, unknown>,
|
||||
): Record<ToggleKey, boolean> {
|
||||
const env = cfg?.env as Record<string, unknown> | undefined;
|
||||
const attr = cfg?.attribution as Record<string, unknown> | 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<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
) {
|
||||
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<string, unknown>,
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
if (Object.keys(target[key] as Record<string, unknown>).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<Record<string, unknown>>(
|
||||
"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<string, unknown>;
|
||||
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 (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{(
|
||||
[
|
||||
["hideAttribution", "claudeConfig.hideAttribution"],
|
||||
["alwaysThinking", "claudeConfig.alwaysThinking"],
|
||||
["enableTeammates", "claudeConfig.enableTeammates"],
|
||||
] as const
|
||||
).map(([key, i18nKey]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||
>
|
||||
<Checkbox checked={states[key]} onCheckedChange={() => toggle(key)} />
|
||||
{t(i18nKey)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<Label htmlFor="settingsConfig">
|
||||
{t("claudeConfig.configLabel")}
|
||||
</Label>
|
||||
<ClaudeQuickToggles
|
||||
onPatchApplied={(patch) => {
|
||||
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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<JsonEditor
|
||||
value={form.getValues("settingsConfig")}
|
||||
value={form.watch("settingsConfig")}
|
||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
|
||||
Reference in New Issue
Block a user