import { useEffect, useState } from "react"; import type { ClaudeCodeSettings } from "../../lib/types"; import Toggle from "../ui/Toggle"; import { SwitchRow, selectClass } from "../ui/Field"; interface Props { settings: ClaudeCodeSettings | null; disabled: boolean; disabledReason?: string; onSave: (settings: ClaudeCodeSettings | null) => Promise; /** * `"project"` adds a third "Global" state to every switch, because a project * has somewhere to inherit *from*. The global editor has no such fallback — * unset there just means Claude Code's own default — so it stays a plain * on/off and never renders the extra choice. */ scope?: "global" | "project"; } export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = { tui_mode: null, effort: null, auto_scroll_disabled: null, focus_mode: null, show_thinking_summaries: null, session_recap_disabled: null, env_scrub: null, prompt_caching_1h: null, }; /** * "Nothing is set at this level", which is saved as `null` rather than as a * struct of nulls so that a project with no opinion is indistinguishable from * one that never opened this editor. * * Note `false` is *not* a default any more: it is a deliberate off that * overrides a global on, so a settings object holding one has to be persisted. */ function isAllDefaults(s: ClaudeCodeSettings): boolean { // `== null`, not `===`: an unset field is *absent* on the wire, not null. // The Rust struct skips serialising one it has no value for, so a project // whose stored settings were all "unset" arrives here as `{}` — and reading // that as "off" is exactly the mistake the three-state control exists to // avoid. See the note on `ClaudeCodeSettings` in `lib/types.ts`. return ( s.tui_mode == null && s.effort == null && s.auto_scroll_disabled == null && s.focus_mode == null && s.show_thinking_summaries == null && s.session_recap_disabled == null && s.env_scrub == null && s.prompt_caching_1h == null ); } /** * Two of Claude Code's settings are **on by default**, so the field behind them * stores the *disabled* sense (`auto_scroll_disabled`, `session_recap_disabled`) * — that is what makes an untouched project mean "leave Claude Code alone" * rather than "the user turned this off". `invert` is what lets those still * read as an ordinary on/off switch here: the toggle shows the feature's state, * the field stores the deviation from the default. */ const BOOLEAN_FIELDS: { key: keyof Omit; label: string; hint: string; invert?: boolean; }[] = [ { key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." }, { key: "show_thinking_summaries", label: "Thinking summaries", hint: "Shows Claude's thinking process as summaries.", }, { key: "session_recap_disabled", label: "Session recap", hint: "Shows a one-line recap when you return to the terminal after a few minutes away.", invert: true, }, { key: "auto_scroll_disabled", label: "Auto-scroll", hint: "Follows new output to the bottom in fullscreen rendering.", invert: true, }, { key: "env_scrub", label: "Env scrub", hint: "Strips credentials from subprocess environments.", }, { key: "prompt_caching_1h", label: "Prompt caching (1h)", hint: "Uses a 1-hour prompt cache TTL instead of 5 minutes.", }, ]; export default function ClaudeCodeSettingsEditor({ settings, disabled, disabledReason, onSave, scope = "global", }: Props) { const [local, setLocal] = useState( settings ?? { ...CLAUDE_CODE_DEFAULTS }, ); useEffect(() => { setLocal(settings ?? { ...CLAUDE_CODE_DEFAULTS }); }, [settings]); const apply = (patch: Partial) => { const next = { ...local, ...patch }; setLocal(next); onSave(isAllDefaults(next) ? null : next); }; return (
{disabled && disabledReason && (

{disabledReason}

)} {/* Three states, not two. Leaving `tui` unset is what lets Claude Code pick the renderer for itself, which is not the same as pinning the classic one — and the key is now always written (or explicitly deleted), so "Automatic" has to be selectable rather than merely being what you get when nothing is emitted. */} apply({ tui_mode: e.target.value || null })} disabled={disabled} className={selectClass} > } /> apply({ effort: e.target.value || null })} disabled={disabled} className={selectClass} > } /> {BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => { const stored = local[key]; if (scope === "global") { // No level above this one to inherit from, so "unset" and "off" are // the same instruction here and a plain switch is the honest control. // Unset therefore has to *display* as Claude Code's own default — // which for the two inverted fields is on, not off. const checked = invert ? stored !== true : stored === true; return ( { // Collapse back to null at the default rather than storing // a redundant `false`, so an untouched global stays // indistinguishable from one that was never opened. const atDefault = invert ? v : !v; apply({ [key]: atDefault ? null : invert ? !v : v, } as Partial); }} /> } /> ); } // `stored` holds the deviation from Claude Code's default, so an // inverted field reads back the other way round — see BOOLEAN_FIELDS. const selected = stored == null ? "global" : (invert ? !stored : stored) ? "on" : "off"; return ( { const choice = e.target.value; const next = choice === "global" ? null : invert ? choice === "off" : choice === "on"; apply({ [key]: next } as Partial); }} className={selectClass} > } /> ); })}
); }