Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ release/
apps/web/.playwright
apps/web/playwright-report
apps/web/src/components/__screenshots__
.vitest-*
30 changes: 30 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
getAppModelOptions,
getAppSettingsSnapshot,
normalizeCustomModelSlugs,
patchGitTextGenerationModelOverrides,
resolveAppModelSelection,
resolveGitTextGenerationModelSelection,
} from "./appSettings";

const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
Expand Down Expand Up @@ -94,6 +96,7 @@ describe("getAppModelOptions", () => {

expect(options.map((option) => option.slug)).toEqual([
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2-codex",
Expand Down Expand Up @@ -133,6 +136,33 @@ describe("resolveAppModelSelection", () => {
});
});

describe("resolveGitTextGenerationModelSelection", () => {
it("prefers a provider-specific override over the active thread model", () => {
const settings = {
...getAppSettingsSnapshot(),
...patchGitTextGenerationModelOverrides({}, "codex", "gpt-5.4-mini"),
};

expect(resolveGitTextGenerationModelSelection("codex", settings, "gpt-5.4")).toBe(
"gpt-5.4-mini",
);
});

it("falls back to the active thread model when no override is configured", () => {
const settings = getAppSettingsSnapshot();

expect(resolveGitTextGenerationModelSelection("cursor", settings, "opus-4.6-thinking")).toBe(
"opus-4.6-thinking",
);
});

it("uses the provider git default when neither an override nor thread model exists", () => {
const settings = getAppSettingsSnapshot();

expect(resolveGitTextGenerationModelSelection("codex", settings, null)).toBe("gpt-5.4-mini");
});
});

describe("timestamp format defaults", () => {
it("defaults timestamp format to locale", () => {
expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale");
Expand Down
139 changes: 138 additions & 1 deletion apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useCallback } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import {
DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
type ProviderKind,
} from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor";
import { useLocalStorage } from "./hooks/useLocalStorage";
Expand Down Expand Up @@ -41,6 +44,16 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>
amp: new Set(getModelOptions("amp").map((option) => option.slug)),
kilo: new Set(getModelOptions("kilo").map((option) => option.slug)),
};
const PROVIDER_KINDS = [
"codex",
"copilot",
"claudeCode",
"cursor",
"opencode",
"geminiCli",
"amp",
"kilo",
] as const satisfies readonly ProviderKind[];

const AppSettingsSchema = Schema.Struct({
codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
Expand Down Expand Up @@ -91,6 +104,9 @@ const AppSettingsSchema = Schema.Struct({
customKiloModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
gitTextGenerationModelByProvider: Schema.Record(Schema.String, Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some({} as Record<string, string>)),
),
providerLogoAppearance: AppProviderLogoAppearanceSchema.pipe(
Schema.withConstructorDefault(() => Option.some("original")),
),
Expand Down Expand Up @@ -136,6 +152,17 @@ export interface AppModelOption {
name: string;
isCustom: boolean;
}
type ProviderCustomModelSettings = Pick<
AppSettings,
| "customCodexModels"
| "customCopilotModels"
| "customClaudeModels"
| "customCursorModels"
| "customOpencodeModels"
| "customGeminiCliModels"
| "customAmpModels"
| "customKiloModels"
>;

const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});

Expand Down Expand Up @@ -168,6 +195,89 @@ export function normalizeCustomModelSlugs(
return normalizedModels;
}

function normalizeGitTextGenerationModelByProvider(
overrides: Record<string, string>,
): Record<string, string> {
const normalizedOverrides: Partial<Record<ProviderKind, string>> = {};
for (const provider of PROVIDER_KINDS) {
const normalized = normalizeModelSlug(overrides[provider], provider);
if (!normalized) {
continue;
}
normalizedOverrides[provider] = normalized;
}
return normalizedOverrides;
}

export function getCustomModelsForProvider(
settings: ProviderCustomModelSettings,
provider: ProviderKind,
): readonly string[] {
switch (provider) {
case "copilot":
return settings.customCopilotModels;
case "claudeCode":
return settings.customClaudeModels;
case "cursor":
return settings.customCursorModels;
case "opencode":
return settings.customOpencodeModels;
case "geminiCli":
return settings.customGeminiCliModels;
case "amp":
return settings.customAmpModels;
case "kilo":
return settings.customKiloModels;
case "codex":
default:
return settings.customCodexModels;
}
}

export function patchCustomModels(provider: ProviderKind, models: string[]): Partial<AppSettings> {
switch (provider) {
case "copilot":
return { customCopilotModels: models };
case "claudeCode":
return { customClaudeModels: models };
case "cursor":
return { customCursorModels: models };
case "opencode":
return { customOpencodeModels: models };
case "geminiCli":
return { customGeminiCliModels: models };
case "amp":
return { customAmpModels: models };
case "kilo":
return { customKiloModels: models };
case "codex":
default:
return { customCodexModels: models };
}
}

export function getGitTextGenerationModelOverride(
settings: Pick<AppSettings, "gitTextGenerationModelByProvider">,
provider: ProviderKind,
): string | null {
return normalizeModelSlug(settings.gitTextGenerationModelByProvider[provider], provider);
}

export function patchGitTextGenerationModelOverrides(
overrides: AppSettings["gitTextGenerationModelByProvider"],
provider: ProviderKind,
model: string | null | undefined,
): Pick<AppSettings, "gitTextGenerationModelByProvider"> {
const normalized = normalizeModelSlug(model, provider);
const nextOverrides = { ...overrides };
if (normalized) {
nextOverrides[provider] = normalized;
} else {
delete nextOverrides[provider];
}
return { gitTextGenerationModelByProvider: nextOverrides };
}

function normalizeAppSettings(settings: AppSettings): AppSettings {
return {
...settings,
Expand All @@ -179,6 +289,9 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
customGeminiCliModels: normalizeCustomModelSlugs(settings.customGeminiCliModels, "geminiCli"),
customAmpModels: normalizeCustomModelSlugs(settings.customAmpModels, "amp"),
customKiloModels: normalizeCustomModelSlugs(settings.customKiloModels, "kilo"),
gitTextGenerationModelByProvider: normalizeGitTextGenerationModelByProvider(
settings.gitTextGenerationModelByProvider,
),
accentColor: normalizeAccentColor(settings.accentColor),
providerAccentColors: Object.fromEntries(
Object.entries(settings.providerAccentColors)
Expand Down Expand Up @@ -257,6 +370,30 @@ export function resolveAppModelSelection(
);
}

export function resolveGitTextGenerationModelSelection(
provider: ProviderKind,
settings: Pick<
AppSettings,
keyof ProviderCustomModelSettings | "gitTextGenerationModelByProvider"
>,
activeModel: string | null | undefined,
): string {
const customModels = getCustomModelsForProvider(settings, provider);
const overrideModel = getGitTextGenerationModelOverride(settings, provider);
if (overrideModel) {
return resolveAppModelSelection(provider, customModels, overrideModel);
}
const normalizedActiveModel = normalizeModelSlug(activeModel, provider);
if (normalizedActiveModel) {
return resolveAppModelSelection(provider, customModels, normalizedActiveModel);
}
return resolveAppModelSelection(
provider,
customModels,
DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[provider],
);
}

export function getSlashModelOptions(
provider: ProviderKind,
customModels: readonly string[],
Expand Down
Loading