Skip to content
3 changes: 3 additions & 0 deletions src/defaultSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,9 @@ export const DEFAULT_SETTINGS: Settings = {
enableModelCustomizations: true,
enableVoiceMode: false,
enableVoiceConciseOutput: true,
enableReactiveTheme: false,
reactiveThemeDarkId: 'dark',
reactiveThemeLightId: 'light',
},
toolsets: [],
defaultToolset: null,
Expand Down
26 changes: 26 additions & 0 deletions src/patches/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ import { writeScrollEscapeSequenceFilter } from './scrollEscapeSequenceFilter';
import { writeWorktreeMode } from './worktreeMode';
import { writeAllowCustomAgentModels } from './allowCustomAgentModels';
import { writeVoiceMode } from './voiceMode';
import { writeReactiveTheme } from './reactiveTheme';
import { REACTIVE_THEME_WATCHER_JS } from '../reactiveThemeWatcher';
import {
restoreNativeBinaryFromBackup,
restoreClijsFromBackup,
Expand Down Expand Up @@ -422,6 +424,13 @@ const PATCH_DEFINITIONS = [
description:
'Enable /voice command for speech-to-text input (hold Space to record)',
},
{
id: 'reactive-theme',
name: 'Reactive theme switching',
group: PatchGroup.FEATURES,
description:
'Auto-switch theme when OS dark/light mode changes (macOS, Linux, Windows, SSH)',
},
] as const;

/** Union type of all valid patch IDs */
Expand Down Expand Up @@ -876,6 +885,14 @@ export const applyCustomization = async (
),
condition: !!config.settings.misc?.enableVoiceMode,
},
'reactive-theme': {
fn: c =>
writeReactiveTheme(c, {
darkThemeId: config.settings.misc?.reactiveThemeDarkId ?? 'dark',
lightThemeId: config.settings.misc?.reactiveThemeLightId ?? 'light',
}),
condition: !!config.settings.misc?.enableReactiveTheme,
},
};

// ==========================================================================
Expand All @@ -886,6 +903,15 @@ export const applyCustomization = async (
content = patchedContent;
allResults.push(...patchResults);

// ==========================================================================
// Install external files for patches that need them
// ==========================================================================
if (config.settings.misc?.enableReactiveTheme) {
const twJsPath = path.join(CONFIG_DIR, 'tw.js');
fsSync.writeFileSync(twJsPath, REACTIVE_THEME_WATCHER_JS, 'utf8');
debug(`Installed reactive theme watcher: ${twJsPath}`);
}

// ==========================================================================
// Write the modified content back
// ==========================================================================
Expand Down
206 changes: 206 additions & 0 deletions src/patches/reactiveTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { writeReactiveTheme } from './reactiveTheme';
import { clearCaches } from './helpers';

// Synthetic ThemeProvider snippets derived from real CC minified code.
// Each version uses different variable names but the same structure.

// Prefix: module loader + React import for getRequireFuncName / getReactVar to work
const BUN_PREFIX =
'var j=(H,$,A)=>{A=H!=null?H:$};' +
'var n7L=($)=>{var W=Symbol.for("react.transitional.element")};' +
'var fH=j((AtM,r7L)=>{r7L.exports=n7L()});';

// v2.1.89 ThemeProvider (simplified but structurally accurate)
function buildV289(): string {
return (
BUN_PREFIX +
'function rUH(){if(Xv6===void 0)Xv6=uG4()??"dark";return Xv6}' +
'function Ad(H){if(H==="auto")return rUH();return H}' +
'function uG4(){let H=process.env.COLORFGBG;if(!H)return;' +
'let _=H.split(";"),q=_[_.length-1];' +
'if(q===void 0||q==="")return;' +
'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' +
'return K<=6||K===8?"dark":"light"}' +
'var Xv6;' +
'function pG_({children:H,initialState:_,onThemeSave:q=gG4}){' +
'let[K,$]=UG.useState(_??pG4),[O,T]=UG.useState(null),' +
'[z,A]=UG.useState(()=>(_??K)==="auto"?rUH():"dark"),' +
'w=O??K,{internal_querier:f}=W6H();' +
'UG.useEffect(()=>{},[w,f]);' +
'let Y=w==="auto"?z:w,' +
'j=UG.useMemo(()=>({themeSetting:K,' +
'setThemeSetting:(D)=>{if($(D),T(null),D==="auto")A(rUH());q?.(D)},' +
'setPreviewTheme:(D)=>{if(T(D),D==="auto")A(rUH())},' +
'savePreview:()=>{if(O!==null)$(O),T(null),q?.(O)},' +
'cancelPreview:()=>{if(O!==null)T(null)},' +
'currentTheme:Y}),[K,O,Y,q]);' +
'return UG.default.createElement(mG_.Provider,{value:j},H)}'
);
}

// v2.1.87 ThemeProvider (different variable names)
function buildV287(): string {
return (
BUN_PREFIX +
'function IFH(){if(OR4===void 0)OR4=MR4()??"dark";return OR4}' +
'function Ad(H){if(H==="auto")return IFH();return H}' +
'function MR4(){let H=process.env.COLORFGBG;if(!H)return;' +
'let _=H.split(";"),q=_[_.length-1];' +
'if(q===void 0||q==="")return;' +
'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' +
'return K<=6||K===8?"dark":"light"}' +
'var OR4;' +
'function E0_({children:H,initialState:_,onThemeSave:q=JR4}){' +
'let[$,K]=KG.useState(_??JR4),[O,T]=KG.useState(null),' +
'[z,A]=KG.useState(()=>(_??$)==="auto"?IFH():"dark"),' +
'f=O??$,{internal_querier:w}=E_H();' +
'KG.useEffect(()=>{},[f,w]);' +
'let Y=f==="auto"?z:f,' +
'D=KG.useMemo(()=>({themeSetting:$,' +
'setThemeSetting:(j)=>{if(K(j),T(null),j==="auto")A(IFH());q?.(j)},' +
'setPreviewTheme:(j)=>{if(T(j),j==="auto")A(IFH())},' +
'savePreview:()=>{if(O!==null)K(O),T(null),q?.(O)},' +
'cancelPreview:()=>{if(O!==null)T(null)},' +
'currentTheme:Y}),[K,O,Y,q]);' +
'return KG.default.createElement(E0_.Provider,{value:D},H)}'
);
}

// v2.1.86 ThemeProvider (single dep in older versions had two deps too)
function buildV286(): string {
return (
BUN_PREFIX +
'function Cd8(){if(qR6===void 0)qR6=_k5()??"dark";return qR6}' +
'function Ad(H){if(H==="auto")return Cd8();return H}' +
'function _k5(){let H=process.env.COLORFGBG;if(!H)return;' +
'let _=H.split(";"),q=_[_.length-1];' +
'if(q===void 0||q==="")return;' +
'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' +
'return K<=6||K===8?"dark":"light"}' +
'var qR6;' +
'function EGq({children:H,initialState:_,onThemeSave:q=Kk5}){' +
'let[O,z]=zA.useState(_??Kk5),[Y,H2]=zA.useState(null),' +
'[$,w]=zA.useState(()=>(_??O)==="auto"?Cd8():"dark"),' +
'j=Y??O,{internal_querier:J}=yq8();' +
'zA.useEffect(()=>{},[j,J]);' +
'let T=j==="auto"?$:j,' +
'X=zA.useMemo(()=>({themeSetting:O,' +
'setThemeSetting:(D)=>{if(z(D),H2(null),D==="auto")w(Cd8());q?.(D)},' +
'setPreviewTheme:(D)=>{if(H2(D),D==="auto")w(Cd8())},' +
'savePreview:()=>{if(Y!==null)z(Y),H2(null),q?.(Y)},' +
'cancelPreview:()=>{if(Y!==null)H2(null)},' +
'currentTheme:T}),[O,Y,T,q]);' +
'return zA.default.createElement(EGq.Provider,{value:X},H)}'
);
}

const DEFAULT_CONFIG = { darkThemeId: 'dark', lightThemeId: 'light' };

describe('reactiveTheme', () => {
beforeEach(() => {
clearCaches();
});

// ====================================================================
// useEffect patching
// ====================================================================

it('patches the empty useEffect in v2.1.89 style code', () => {
const result = writeReactiveTheme(buildV289(), DEFAULT_CONFIG);

expect(result).not.toBeNull();
expect(result).toContain('.tweakcc","tw.js"');
expect(result).toContain('(A,f,"dark","light")');
expect(result).not.toContain('.useEffect(()=>{}');
});

it('patches the empty useEffect in v2.1.87 style code', () => {
clearCaches();
const result = writeReactiveTheme(buildV287(), DEFAULT_CONFIG);

expect(result).not.toBeNull();
expect(result).toContain('.tweakcc","tw.js"');
expect(result).toContain('(A,w,"dark","light")');
expect(result).not.toContain('.useEffect(()=>{}');
});

it('patches the empty useEffect in v2.1.86 style code', () => {
clearCaches();
const result = writeReactiveTheme(buildV286(), DEFAULT_CONFIG);

expect(result).not.toBeNull();
expect(result).toContain('.tweakcc","tw.js"');
expect(result).toContain('(w,J,"dark","light")');
expect(result).not.toContain('.useEffect(()=>{}');
});

it('uses custom theme IDs from config', () => {
const result = writeReactiveTheme(buildV289(), {
darkThemeId: 'dark-ansi',
lightThemeId: 'light-ansi',
});

expect(result).not.toBeNull();
expect(result).toContain('"dark-ansi","light-ansi"');
});

// ====================================================================
// COLORFGBG detect patching
// ====================================================================

it('patches the COLORFGBG detect function', () => {
const result = writeReactiveTheme(buildV289(), DEFAULT_CONFIG);

expect(result).not.toBeNull();
expect(result).toContain('defaults read -g AppleInterfaceStyle');
expect(result).toContain('org.freedesktop.appearance color-scheme');
expect(result).toContain('AppsUseLightTheme');
// COLORFGBG preserved as fallback
expect(result).toContain('process.env.COLORFGBG');
});

it('preserves the original function name in detect replacement', () => {
const result = writeReactiveTheme(buildV289(), DEFAULT_CONFIG);

expect(result).not.toBeNull();
expect(result).toContain('function uG4(){try{');
});

// ====================================================================
// Idempotency
// ====================================================================

it('returns content unchanged when already patched', () => {
const first = writeReactiveTheme(buildV289(), DEFAULT_CONFIG);
expect(first).not.toBeNull();

clearCaches();
const second = writeReactiveTheme(first!, DEFAULT_CONFIG);
expect(second).toBe(first);
});

// ====================================================================
// Failure cases
// ====================================================================

it('returns null when ThemeProvider is not found', () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});

try {
const result = writeReactiveTheme(
BUN_PREFIX + 'const x = 1;',
DEFAULT_CONFIG
);
expect(result).toBeNull();
expect(consoleError).toHaveBeenCalledWith(
'patch: reactiveTheme: failed to find ThemeProvider function'
);
} finally {
consoleError.mockRestore();
}
});
});
Loading