Skip to content
3 changes: 3 additions & 0 deletions src/defaultSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,9 @@ export const DEFAULT_SETTINGS: Settings = {
enableVoiceMode: false,
enableVoiceConciseOutput: true,
enableChannelsMode: false,
enableReactiveTheme: false,
reactiveThemeDarkId: 'dark',
reactiveThemeLightId: 'light',
},
toolsets: [],
defaultToolset: null,
Expand Down
44 changes: 44 additions & 0 deletions src/patches/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ import { writeWorktreeMode } from './worktreeMode';
import { writeAllowCustomAgentModels } from './allowCustomAgentModels';
import { writeVoiceMode } from './voiceMode';
import { writeChannelsMode } from './channelsMode';
import { writeReactiveTheme } from './reactiveTheme';
import { writeThemeDetection } from './themeDetection';
import { REACTIVE_THEME_WATCHER_JS } from '../reactiveThemeWatcher';
import {
restoreNativeBinaryFromBackup,
restoreClijsFromBackup,
Expand Down Expand Up @@ -174,6 +177,13 @@ const PATCH_DEFINITIONS = [
group: PatchGroup.ALWAYS_APPLIED,
description: `Statusline updates will be properly throttled instead of queued (or debounced)`,
},
{
id: 'fix-theme-detection',
name: 'Fix theme detection',
group: PatchGroup.ALWAYS_APPLIED,
description:
'Cross-platform "auto" theme detection (macOS defaults, Linux gdbus, Windows registry) instead of COLORFGBG',
},
// Misc Configurable
{
id: 'model-customizations',
Expand Down Expand Up @@ -430,6 +440,13 @@ const PATCH_DEFINITIONS = [
description:
'Enable MCP channel notifications (--channels without allowlist or dev flag)',
},
{
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 @@ -663,6 +680,9 @@ export const applyCustomization = async (
),
condition: config.settings.misc?.statuslineThrottleMs != null,
},
'fix-theme-detection': {
fn: c => writeThemeDetection(c),
},
// Misc Configurable
'patches-applied-indication': {
fn: c =>
Expand Down Expand Up @@ -888,6 +908,14 @@ export const applyCustomization = async (
fn: c => writeChannelsMode(c),
condition: !!config.settings.misc?.enableChannelsMode,
},
'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 @@ -898,6 +926,22 @@ export const applyCustomization = async (
content = patchedContent;
allResults.push(...patchResults);

// ==========================================================================
// Install external files for patches that need them
// ==========================================================================
const twJsPath = path.join(CONFIG_DIR, 'tw.js');
if (config.settings.misc?.enableReactiveTheme) {
fsSync.writeFileSync(twJsPath, REACTIVE_THEME_WATCHER_JS, 'utf8');
debug(`Installed reactive theme watcher: ${twJsPath}`);
} else {
try {
fsSync.unlinkSync(twJsPath);
debug(`Removed reactive theme watcher: ${twJsPath}`);
} catch {
// File doesn't exist — nothing to clean up
}
}

// ==========================================================================
// Write the modified content back
// ==========================================================================
Expand Down
182 changes: 182 additions & 0 deletions src/patches/reactiveTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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('/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', () => {
const result = writeReactiveTheme(buildV287(), DEFAULT_CONFIG);

expect(result).not.toBeNull();
expect(result).toContain('/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', () => {
const result = writeReactiveTheme(buildV286(), DEFAULT_CONFIG);

expect(result).not.toBeNull();
expect(result).toContain('/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"');
});

// ====================================================================
// 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();
}
});
});
110 changes: 110 additions & 0 deletions src/patches/reactiveTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { escapeIdent, showDiff } from './index';
import { CONFIG_DIR } from '../config';

export interface ReactiveThemeConfig {
darkThemeId: string;
lightThemeId: string;
}

function findThemeProviderRegion(content: string): {
start: number;
end: number;
} | null {
const marker = /\{themeSetting:[$\w]+,[\s\S]*?currentTheme:[$\w]+\}/;
const match = content.match(marker);
if (!match || match.index == null) return null;

const searchStart = Math.max(0, match.index - 3000);
const before = content.slice(searchStart, match.index);
const funcMatch = before.match(
/function [$\w]+\(\{children:[$\w]+,initialState:[$\w]+[^}]*\}\)\{/g
);
if (!funcMatch) return null;

const lastFunc = funcMatch[funcMatch.length - 1];
const funcStart = before.lastIndexOf(lastFunc);
if (funcStart === -1) return null;

const start = searchStart + funcStart;

const after = content.slice(match.index, match.index + 2000);
const returnMatch = after.match(
/return [$\w]+\.default\.createElement\([$\w]+\.Provider,\{value:[$\w]+\},[$\w]+\)\}/
);
if (!returnMatch || returnMatch.index == null) return null;

const end = match.index + returnMatch.index + returnMatch[0].length;
return { start, end };
}

function patchThemeProviderUseEffect(
content: string,
config: ReactiveThemeConfig
): string | null {
const region = findThemeProviderRegion(content);
if (!region) {
console.error(
'patch: reactiveTheme: failed to find ThemeProvider function'
);
return null;
}

const regionContent = content.slice(region.start, region.end);

if (regionContent.includes('/tw.js")')) {
return content;
}

const useEffectPattern =
/([$\w]+)\.useEffect\(\(\)=>\{\},\[([$\w]+)(?:,([$\w]+))?\]\)/;
const useEffectMatch = regionContent.match(useEffectPattern);
if (!useEffectMatch || useEffectMatch.index == null) {
console.error(
'patch: reactiveTheme: failed to find empty useEffect in ThemeProvider'
);
return null;
}

const reactVar = useEffectMatch[1];
const dep1 = useEffectMatch[2];
const dep2 = useEffectMatch[3];
const deps = dep2 ? `${dep1},${dep2}` : dep1;

const setStatePattern = new RegExp(
`\\[([$\\w]+),([$\\w]+)\\]=${escapeIdent(reactVar)}\\.useState\\(\\(\\)=>\\([^)]+\\)==="?auto"?\\?[$\\w]+\\(\\):"dark"\\)`
);
const setStateMatch = regionContent.match(setStatePattern);
if (!setStateMatch) {
console.error(
'patch: reactiveTheme: failed to find setState for resolved theme'
);
return null;
}

const setStateVar = setStateMatch[2];

const querierArg = dep2 ? `,${dep2}` : ',void 0';
const twJsPath = CONFIG_DIR + '/tw.js';
const replacement =
`${reactVar}.useEffect(()=>{try{if(${dep1}=="auto"){` +
`return require(${JSON.stringify(twJsPath)})` +
`(${setStateVar}${querierArg},${JSON.stringify(config.darkThemeId)},${JSON.stringify(config.lightThemeId)},${JSON.stringify(CONFIG_DIR)})` +
`}}catch{}},[${deps}])`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const absStart = region.start + useEffectMatch.index;
const absEnd = absStart + useEffectMatch[0].length;

const newContent =
content.slice(0, absStart) + replacement + content.slice(absEnd);

showDiff(content, newContent, replacement, absStart, absEnd);

return newContent;
}

export const writeReactiveTheme = (
oldFile: string,
config: ReactiveThemeConfig
): string | null => {
return patchThemeProviderUseEffect(oldFile, config);
};
Loading