Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/defaultSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ export const DEFAULT_SETTINGS: Settings = {
suppressNativeInstallerWarning: false,
filterScrollEscapeSequences: false,
enableWorktreeMode: true,
enableModelCustomizations: true,
},
toolsets: [],
defaultToolset: null,
Expand Down
42 changes: 24 additions & 18 deletions src/patches/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,25 +153,13 @@ 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',
group: PatchGroup.ALWAYS_APPLIED,
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',
Expand All @@ -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',
Expand Down Expand Up @@ -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<PatchId, PatchImplementation> = {
// Always Applied
'verbose-property': {
Expand All @@ -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:
Expand Down Expand Up @@ -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',
Expand Down
148 changes: 148 additions & 0 deletions src/patches/modelCustomizationsToggle.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 21 additions & 17 deletions src/patches/modelSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
42 changes: 42 additions & 0 deletions src/patches/opusplan1m.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading