diff --git a/src/defaultSettings.ts b/src/defaultSettings.ts index 8578385f..e2ea9743 100644 --- a/src/defaultSettings.ts +++ b/src/defaultSettings.ts @@ -713,6 +713,7 @@ export const DEFAULT_SETTINGS: Settings = { suppressNativeInstallerWarning: false, filterScrollEscapeSequences: false, enableWorktreeMode: true, + enableModelCustomizations: true, }, toolsets: [], defaultToolset: null, diff --git a/src/patches/index.ts b/src/patches/index.ts index 43fbffa8..0e037ef7 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -153,12 +153,6 @@ const PATCH_DEFINITIONS = [ description: 'Set the CLAUDE_CODE_CONTEXT_LIMIT env var to change 200k max for custom models', }, - { - id: 'model-customizations', - name: 'Model customizations', - group: PatchGroup.ALWAYS_APPLIED, - description: 'Access all Claude models with /model, not just latest 3', - }, { id: 'opusplan1m', name: 'Opusplan[1m] support', @@ -166,12 +160,6 @@ const PATCH_DEFINITIONS = [ description: 'Use the "Opus Plan 1M" model: Opus for planning, Sonnet 1M context for building', }, - { - id: 'show-more-items-in-select-menus', - name: 'Show more items in select menus', - group: PatchGroup.ALWAYS_APPLIED, - description: 'Show 25 items in select menus instead of default 5', - }, { id: 'thinking-block-styling', name: 'Thinking block styling', @@ -191,6 +179,18 @@ const PATCH_DEFINITIONS = [ description: `Statusline updates will be properly throttled instead of queued (or debounced)`, }, // Misc Configurable + { + id: 'model-customizations', + name: 'Model customizations', + group: PatchGroup.MISC_CONFIGURABLE, + description: 'Access all Claude models with /model, not just latest 3', + }, + { + id: 'show-more-items-in-select-menus', + name: 'Show more items in select menus', + group: PatchGroup.MISC_CONFIGURABLE, + description: 'Show 25 items in select menus instead of default 5', + }, { id: 'patches-applied-indication', name: 'Patches applied indication', @@ -605,6 +605,10 @@ export const applyCustomization = async ( // ========================================================================== // Define patch implementations (keyed by PatchId) // ========================================================================== + // Keep model list customization and select-menu size behavior in sync. + // Disabling model customizations should restore both selectors to vanilla CC behavior. + const modelCustomizationsEnabled = + config.settings.misc?.enableModelCustomizations !== false; const patchImplementations: Record = { // Always Applied 'verbose-property': { @@ -613,15 +617,9 @@ export const applyCustomization = async ( 'context-limit': { fn: c => writeContextLimit(c), }, - 'model-customizations': { - fn: c => writeModelCustomizations(c), - }, opusplan1m: { fn: c => writeOpusplan1m(c), }, - 'show-more-items-in-select-menus': { - fn: c => writeShowMoreItemsInSelectMenus(c, 25), - }, 'thinking-block-styling': { fn: c => writeThinkingBlockStyling(c), condition: @@ -651,6 +649,14 @@ export const applyCustomization = async ( showPatchesApplied ), }, + 'model-customizations': { + fn: c => writeModelCustomizations(c), + condition: modelCustomizationsEnabled, + }, + 'show-more-items-in-select-menus': { + fn: c => writeShowMoreItemsInSelectMenus(c, 25), + condition: modelCustomizationsEnabled, + }, 'table-format': { fn: c => writeTableFormat(c, tableFormat), condition: tableFormat !== 'default', diff --git a/src/patches/modelCustomizationsToggle.test.ts b/src/patches/modelCustomizationsToggle.test.ts new file mode 100644 index 00000000..eaae3a0a --- /dev/null +++ b/src/patches/modelCustomizationsToggle.test.ts @@ -0,0 +1,148 @@ +import * as fs from 'node:fs/promises'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_SETTINGS } from '../defaultSettings'; +import { ClaudeCodeInstallationInfo, TweakccConfig } from '../types'; +import { updateConfigFile } from '../config'; +import { replaceFileBreakingHardLinks } from '../utils'; +import { restoreClijsFromBackup } from '../installationBackup'; +import { writeModelCustomizations } from './modelSelector'; +import { writeShowMoreItemsInSelectMenus } from './showMoreItemsInSelectMenus'; +import { applySystemPrompts } from './systemPrompts'; +import { applyCustomization } from './index'; + +const mockReadFile = vi.hoisted(() => vi.fn()); + +vi.mock('node:fs/promises', () => ({ + readFile: mockReadFile, +})); + +vi.mock('../config', () => ({ + CONFIG_DIR: '/tmp/tweakcc-test-config', + NATIVE_BINARY_BACKUP_FILE: '/tmp/tweakcc-test-config/native.backup', + updateConfigFile: vi.fn(async updateFn => { + const config = { changesApplied: false } as TweakccConfig; + updateFn(config); + return config; + }), +})); + +vi.mock('../utils', () => ({ + debug: vi.fn(), + replaceFileBreakingHardLinks: vi.fn(), +})); + +vi.mock('../installationBackup', () => ({ + restoreNativeBinaryFromBackup: vi.fn(), + restoreClijsFromBackup: vi.fn(async () => true), +})); + +vi.mock('../nativeInstallationLoader', () => ({ + extractClaudeJsFromNativeInstallation: vi.fn(), + repackNativeInstallation: vi.fn(), +})); + +vi.mock('./modelSelector', () => ({ + writeModelCustomizations: vi.fn((content: string) => `${content}|model`), +})); + +vi.mock('./showMoreItemsInSelectMenus', () => ({ + writeShowMoreItemsInSelectMenus: vi.fn( + (content: string) => `${content}|show` + ), +})); + +vi.mock('./systemPrompts', () => ({ + applySystemPrompts: vi.fn(async (content: string) => ({ + newContent: content, + results: [], + })), +})); + +const baseConfig = (): TweakccConfig => ({ + ccVersion: '', + ccInstallationPath: null, + lastModified: '2026-01-01T00:00:00.000Z', + changesApplied: false, + settings: { + ...DEFAULT_SETTINGS, + misc: { + ...DEFAULT_SETTINGS.misc, + }, + }, +}); + +const ccInstInfo: ClaudeCodeInstallationInfo = { + cliPath: '/tmp/claude-cli.js', + version: '2.1.63', + source: 'search-paths', +}; + +describe('model customization toggle patch conditions', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.readFile).mockResolvedValue('base-content'); + }); + + it('skips both model customization patches when disabled', async () => { + const config = baseConfig(); + config.settings.misc.enableModelCustomizations = false; + + const { results } = await applyCustomization(config, ccInstInfo, [ + 'model-customizations', + 'show-more-items-in-select-menus', + ]); + + const modelResult = results.find(r => r.id === 'model-customizations'); + const showMoreResult = results.find( + r => r.id === 'show-more-items-in-select-menus' + ); + + expect(modelResult).toMatchObject({ applied: false, skipped: true }); + expect(showMoreResult).toMatchObject({ applied: false, skipped: true }); + expect(vi.mocked(writeModelCustomizations)).not.toHaveBeenCalled(); + expect(vi.mocked(writeShowMoreItemsInSelectMenus)).not.toHaveBeenCalled(); + expect(vi.mocked(replaceFileBreakingHardLinks)).toHaveBeenCalledWith( + '/tmp/claude-cli.js', + 'base-content', + 'patch' + ); + }); + + it('applies both model customization patches when enabled', async () => { + const config = baseConfig(); + config.settings.misc.enableModelCustomizations = true; + + const { results } = await applyCustomization(config, ccInstInfo, [ + 'model-customizations', + 'show-more-items-in-select-menus', + ]); + + const modelResult = results.find(r => r.id === 'model-customizations'); + const showMoreResult = results.find( + r => r.id === 'show-more-items-in-select-menus' + ); + + expect(modelResult).toMatchObject({ applied: true, failed: false }); + expect(showMoreResult).toMatchObject({ applied: true, failed: false }); + expect(vi.mocked(writeModelCustomizations)).toHaveBeenCalledTimes(1); + expect(vi.mocked(writeShowMoreItemsInSelectMenus)).toHaveBeenCalledTimes(1); + expect(vi.mocked(replaceFileBreakingHardLinks)).toHaveBeenCalledWith( + '/tmp/claude-cli.js', + expect.stringContaining('base-content'), + 'patch' + ); + }); + + it('runs plumbing required for apply customization', async () => { + await applyCustomization(baseConfig(), ccInstInfo, [ + 'model-customizations', + 'show-more-items-in-select-menus', + ]); + + expect(vi.mocked(restoreClijsFromBackup)).toHaveBeenCalledTimes(1); + expect(vi.mocked(applySystemPrompts)).toHaveBeenCalledTimes(1); + expect(vi.mocked(updateConfigFile)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/patches/modelSelector.ts b/src/patches/modelSelector.ts index 9226f2c1..5630cb0b 100644 --- a/src/patches/modelSelector.ts +++ b/src/patches/modelSelector.ts @@ -5,18 +5,20 @@ import { escapeIdent, showDiff } from './index'; // Models to inject/make available. // prettier-ignore export const CUSTOM_MODELS: { value: string; label: string; description: string }[] = [ - { value: 'claude-opus-4-6', label: 'Opus 4.6', description: "Claude Opus 4.6 (February 2026)" }, - { value: 'claude-opus-4-5-20251101', label: 'Opus 4.5', description: "Claude Opus 4.5 (November 2025)" }, - { value: 'claude-sonnet-4-5-20250929', label: 'Sonnet 4.5', description: "Claude Sonnet 4.5 (September 2025)" }, - { value: 'claude-opus-4-1-20250805', label: 'Opus 4.1', description: "Claude Opus 4.1 (August 2025)" }, - { value: 'claude-opus-4-20250514', label: 'Opus 4', description: "Claude Opus 4 (May 2025)" }, - { value: 'claude-sonnet-4-20250514', label: 'Sonnet 4', description: "Claude Sonnet 4 (May 2025)" }, - { value: 'claude-3-7-sonnet-20250219', label: 'Sonnet 3.7', description: "Claude 3.7 Sonnet (February 2025)" }, - { value: 'claude-3-5-sonnet-20241022', label: 'Sonnet 3.5 (October)', description: "Claude 3.5 Sonnet (October 2024)" }, - { value: 'claude-3-5-haiku-20241022', label: 'Haiku 3.5', description: "Claude 3.5 Haiku (October 2024)" }, - { value: 'claude-3-5-sonnet-20240620', label: 'Sonnet 3.5 (June)', description: "Claude 3.5 Sonnet (June 2024)" }, - { value: 'claude-3-haiku-20240307', label: 'Haiku 3', description: "Claude 3 Haiku (March 2024)" }, - { value: 'claude-3-opus-20240229', label: 'Opus 3', description: "Claude 3 Opus (February 2024)" }, + { value: 'claude-opus-4-6', label: 'Opus 4.6', description: "Claude Opus 4.6 (February 2026)" }, + { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6', description: "Claude Sonnet 4.6 (February 2026)" }, + { value: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', description: "Claude Haiku 4.5 (October 2025)" }, + { value: 'claude-opus-4-5-20251101', label: 'Opus 4.5', description: "Claude Opus 4.5 (November 2025)" }, + { value: 'claude-sonnet-4-5-20250929', label: 'Sonnet 4.5', description: "Claude Sonnet 4.5 (September 2025)" }, + { value: 'claude-opus-4-1-20250805', label: 'Opus 4.1', description: "Claude Opus 4.1 (August 2025)" }, + { value: 'claude-opus-4-20250514', label: 'Opus 4', description: "Claude Opus 4 (May 2025)" }, + { value: 'claude-sonnet-4-20250514', label: 'Sonnet 4', description: "Claude Sonnet 4 (May 2025)" }, + { value: 'claude-3-7-sonnet-20250219', label: 'Sonnet 3.7', description: "Claude 3.7 Sonnet (February 2025)" }, + { value: 'claude-3-5-sonnet-20241022', label: 'Sonnet 3.5 (October)', description: "Claude 3.5 Sonnet (October 2024)" }, + { value: 'claude-3-5-haiku-20241022', label: 'Haiku 3.5', description: "Claude 3.5 Haiku (October 2024)" }, + { value: 'claude-3-5-sonnet-20240620', label: 'Sonnet 3.5 (June)', description: "Claude 3.5 Sonnet (June 2024)" }, + { value: 'claude-3-haiku-20240307', label: 'Haiku 3', description: "Claude 3 Haiku (March 2024)" }, + { value: 'claude-3-opus-20240229', label: 'Opus 3', description: "Claude 3 Opus (February 2024)" }, ]; const findCustomModelListInsertionPoint = ( @@ -36,13 +38,15 @@ const findCustomModelListInsertionPoint = ( // 2. Extract the model list variable name const modelListVar = pushMatch[1]; - // 3. Look back 600 chars from the push match - const searchStart = Math.max(0, pushMatch.index - 600); + // The declaration/function head can move farther from the push site across CC builds + // and when other patches expand this block, so keep a wider lookback window. + const searchStart = Math.max(0, pushMatch.index - 1500); const chunk = fileContents.slice(searchStart, pushMatch.index); - // 4. Find the LAST occurrence of the function with let modelListVar=...; + // Declaration can be emitted as let/var/const depending on minifier output. + const declPattern = `(?:let|var|const) ${escapeIdent(modelListVar)}=.+?;`; const funcPattern = new RegExp( - `function [$\\w]+\\([^)]*\\)\\{let ${escapeIdent(modelListVar)}=.+?;`, + `function [$\\w]+\\([^)]*\\)\\{${declPattern}`, 'g' ); let lastMatch: RegExpExecArray | null = null; @@ -53,7 +57,7 @@ const findCustomModelListInsertionPoint = ( if (!lastMatch) { console.error( - `patch: findCustomModelListInsertionPoint: failed to find function with let ${modelListVar}` + `patch: findCustomModelListInsertionPoint: failed to find function with ${modelListVar}` ); return null; } diff --git a/src/patches/opusplan1m.test.ts b/src/patches/opusplan1m.test.ts new file mode 100644 index 00000000..79453a92 --- /dev/null +++ b/src/patches/opusplan1m.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { writeOpusplan1m } from './opusplan1m'; + +const createInput = (withWrapper: boolean): string => { + const selectorReturn = withWrapper + ? 'if(K==="opusplan")return v1A([...A,Mm3()]);' + : 'if(K==="opusplan")return [...A,Mm3()];'; + const listReturn = withWrapper + ? 'if(K===null||A.some((X)=>X.value===K))return v1A(A);' + : 'if(K===null||A.some((X)=>X.value===K))return A;'; + + return `function z(){if(K8A()==="opusplan"&&K==="plan"&&!Y)return q8A();let k0A=["sonnet","opus","haiku","sonnet[1m]","opusplan"];if(T==="opusplan")return"Opus 4.6 in plan mode, else Sonnet 4.6";if(T==="opusplan")return"Opus Plan";${listReturn}${selectorReturn}return A;}`; +}; + +describe('writeOpusplan1m', () => { + it('keeps output syntactically valid for wrapped selector returns', () => { + const output = writeOpusplan1m(createInput(true)); + + expect(output).toBeTruthy(); + expect(output).toContain( + 'if(T==="opusplan")return"Opus 4.6 in plan mode, else Sonnet 4.6";if(T==="opusplan[1m]")return"Opus 4.6 in plan mode, else Sonnet 4.6 (1M context)";' + ); + expect(output).toContain('if(K==="opusplan[1m]")return v1A(['); + expect(output).not.toContain('returnv1A'); + expect(() => new Function(output!)).not.toThrow(); + }); + + it('keeps output syntactically valid for bare array selector returns', () => { + const output = writeOpusplan1m(createInput(false)); + + expect(output).toBeTruthy(); + expect(output).toContain('if(K==="opusplan[1m]")return [...A,'); + expect(() => new Function(output!)).not.toThrow(); + }); + + it('returns null for unmatched input', () => { + const output = writeOpusplan1m('function z(){return 1;}'); + + expect(output).toBeNull(); + }); +}); diff --git a/src/patches/opusplan1m.ts b/src/patches/opusplan1m.ts index 57311314..85331956 100644 --- a/src/patches/opusplan1m.ts +++ b/src/patches/opusplan1m.ts @@ -102,16 +102,13 @@ const patchModelAliasesList = (oldFile: string): string | null => { * Patch 3: Fix the description function (Zm3) to handle opusplan[1m] * * Original: - * if (A === "opusplan") return "Opus 4.5 in plan mode, else Sonnet 4.5"; - * + * if (A === "opusplan") return "Opus 4.6 in plan mode, else Sonnet 4.6"; * Patched: - * if (A === "opusplan") return "Opus 4.5 in plan mode, else Sonnet 4.5"; - * if (A === "opusplan[1m]") return "Opus 4.5 in plan mode, else Sonnet 4.5 (1M context)"; + * if (A === "opusplan") return "Opus 4.6 in plan mode, else Sonnet 4.6"; + * if (A === "opusplan[1m]") return "Opus 4.6 in plan mode, else Sonnet 4.6 (1M context)"; */ const patchDescriptionFunction = (oldFile: string): string | null => { - // Pattern matches: if (VAR === "opusplan") return "Opus 4.5 in plan mode, else Sonnet 4.5"; - const pattern = - /(if\s*\(\s*([$\w]+)\s*===\s*"opusplan"\s*\)\s*return\s*"Opus .{0,20} in plan mode, else Sonnet .{0,20}";)/; + const pattern = /(if\(([$A-Za-z_][\w$]*)==="opusplan"\)return"[^"]*";)/; const match = oldFile.match(pattern); if (!match || match.index === undefined) { @@ -123,10 +120,9 @@ const patchDescriptionFunction = (oldFile: string): string | null => { const [fullMatch, , varName] = match; - // Add the opusplan[1m] case right after the opusplan case const replacement = - fullMatch + - `if(${varName}==="opusplan[1m]")return"Opus 4.5 in plan mode, else Sonnet 4.5 (1M context)";`; + `if(${varName}==="opusplan")return"Opus 4.6 in plan mode, else Sonnet 4.6";` + + `if(${varName}==="opusplan[1m]")return"Opus 4.6 in plan mode, else Sonnet 4.6 (1M context)";`; const newFile = oldFile.slice(0, match.index) + @@ -209,9 +205,10 @@ const patchModelSelectorOptions = (oldFile: string): string | null => { // Old pattern: if (K === "opusplan") return [...A, Mm3()]; // New pattern: if (K === "opusplan") return v1A([...A, Mm3()]); // We need to add a similar case for opusplan[1m] - // Capture groups: 1=fullMatch, 2=conditionVar (K), 3=listVar (A), 4=funcName (Mm3) + // Capture groups: + // 1=fullMatch, 2=conditionVar (K), 3=optional wrapper fn (v1A), 4=listVar (A), 5=funcName (Mm3) const pattern = - /(if\s*\(\s*([$\w]+)\s*===\s*"opusplan"\s*\)\s*return\s*(?:[$\w]+\()?\[\s*\.\.\.([$\w]+)\s*,\s*([$\w]+)\(\)\s*\]\)?;)/; + /(if\(([$A-Za-z_][\w$]*)==="opusplan"\)return (?:([$A-Za-z_][\w$]*)\()?\[\.\.\.([$A-Za-z_][\w$]*),([$A-Za-z_][\w$]*)\(\)\]\)?;)/; const match = oldFile.match(pattern); if (!match || match.index === undefined) { @@ -221,13 +218,12 @@ const patchModelSelectorOptions = (oldFile: string): string | null => { return null; } - const [fullMatch, , varName, listVar] = match; - - // Add the opusplan[1m] case right after. We create an inline object instead of a function - // since we don't want to modify the function definitions area + const [fullMatch, , varName, wrapFn, listVar] = match; + const returnExpr = wrapFn + ? `${wrapFn}([...${listVar},{value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus 4.6 in plan mode, Sonnet 4.6 (1M context) otherwise"}])` + : `[...${listVar},{value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus 4.6 in plan mode, Sonnet 4.6 (1M context) otherwise"}]`; const replacement = - fullMatch + - `if(${varName}==="opusplan[1m]")return[...${listVar},{value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus 4.5 in plan mode, Sonnet 4.5 (1M context) otherwise"}];`; + fullMatch + `if(${varName}==="opusplan[1m]")return ${returnExpr};`; const newFile = oldFile.slice(0, match.index) + @@ -272,8 +268,8 @@ const patchAlwaysShowInModelSelector = (oldFile: string): string | null => { // Inject pushes BEFORE the conditional return // This ensures opusplan and opusplan[1m] are always in the list const inject = - `${listVar}.push({value:"opusplan",label:"Opus Plan Mode",description:"Use Opus 4.5 in plan mode, Sonnet 4.5 otherwise"});` + - `${listVar}.push({value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus 4.5 in plan mode, Sonnet 4.5 (1M context) otherwise"});`; + `${listVar}.push({value:"opusplan",label:"Opus Plan Mode",description:"Use Opus 4.6 in plan mode, Sonnet 4.6 otherwise"});` + + `${listVar}.push({value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus 4.6 in plan mode, Sonnet 4.6 (1M context) otherwise"});`; const newFile = oldFile.slice(0, match.index) + inject + oldFile.slice(match.index); diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts index 7dd44ca9..19d7873a 100644 --- a/src/tests/config.test.ts +++ b/src/tests/config.test.ts @@ -165,6 +165,27 @@ describe('config.ts', () => { const result = await readConfigFile(); expect(result).toEqual(expect.objectContaining(mockConfig)); }); + + it('should backfill enableModelCustomizations when missing in misc', async () => { + const misc = { ...DEFAULT_SETTINGS.misc } as Record; + delete misc.enableModelCustomizations; + + const mockConfig = { + ccVersion: '1.0.0', + ccInstallationPath: null, + lastModified: '2024-01-01', + changesApplied: true, + settings: { + ...DEFAULT_SETTINGS, + misc, + }, + }; + + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify(mockConfig)); + const result = await readConfigFile(); + + expect(result.settings.misc.enableModelCustomizations).toBe(true); + }); }); describe('updateConfigFile', () => { diff --git a/src/types.ts b/src/types.ts index 813eb94d..31376629 100644 --- a/src/types.ts +++ b/src/types.ts @@ -131,6 +131,7 @@ export interface MiscConfig { suppressNativeInstallerWarning: boolean; filterScrollEscapeSequences: boolean; enableWorktreeMode: boolean; + enableModelCustomizations: boolean; } export interface InputPatternHighlighter { diff --git a/src/ui/components/MiscView.tsx b/src/ui/components/MiscView.tsx index 71da0828..c771a616 100644 --- a/src/ui/components/MiscView.tsx +++ b/src/ui/components/MiscView.tsx @@ -79,6 +79,7 @@ export function MiscView({ onSubmit }: MiscViewProps) { suppressNativeInstallerWarning: false, filterScrollEscapeSequences: false, enableWorktreeMode: true, + enableModelCustomizations: true, }; const ensureMisc = () => { @@ -223,6 +224,20 @@ export function MiscView({ onSubmit }: MiscViewProps) { }); }, }, + { + id: 'enableModelCustomizations', + title: 'Enable model customizations (/model shows all models)', + description: + 'Show all Claude models in /model menu, not just the latest 3. Disable to use Claude Code default model list.', + getValue: () => settings.misc?.enableModelCustomizations ?? true, + toggle: () => { + updateSettings(settings => { + ensureMisc(); + settings.misc!.enableModelCustomizations = + !settings.misc!.enableModelCustomizations; + }); + }, + }, { id: 'hideStartupBanner', title: 'Hide startup banner',