import { useCallback } from "react";import { Option, Schema } from "effect";import { TrimmedNonEmptyString, type ProviderKind, type ProviderStartOptions,} from "@t3tools/contracts";import { getDefaultModel, getModelOptions, normalizeModelSlug, resolveSelectableModel,} from "@t3tools/shared/model";import { useLocalStorage } from "./hooks/useLocalStorage";import { EnvMode } from "./components/BranchToolbar.logic"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";const MAX_CUSTOM_MODEL_COUNT = 32;export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);export type TimestampFormat = typeof TimestampFormat.Type;export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; defaultSettingsKey: CustomModelSettingsKey; title: string; description: string; placeholder: string; example: string;}; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),}; const withDefaults = < S extends Schema.Top & Schema.WithoutConstructorDefault, D extends S["~type.make.in"] & S["Encoded"], >( fallback: () => D, ) => (schema: S) => schema.pipe( Schema.withConstructorDefault(() => Option.some(fallback())), Schema.withDecodingDefault(() => fallback()), ); export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString),});export type AppSettings = typeof AppSettingsSchema.Type;export interface AppModelOption { slug: string; name: string; isCustom: boolean;} const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = { codex: { provider: "codex", settingsKey: "customCodexModels", defaultSettingsKey: "customCodexModels", title: "Codex", description: "Save additional Codex model slugs for the picker and `/model` command.", placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, claudeAgent: { provider: "claudeAgent", settingsKey: "customClaudeModels", defaultSettingsKey: "customClaudeModels", title: "Claude", description: "Save additional Claude model slugs for the picker and `/model` command.", placeholder: "your-claude-model-slug", example: "claude-sonnet-5-0", },};export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); export function normalizeCustomModelSlugs( models: Iterable<string | null | undefined>, provider: ProviderKind = "codex",): string[] { const normalizedModels: string[] = []; const seen = new Set<string>(); const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; for (const candidate of models) { const normalized = normalizeModelSlug(candidate, provider); if ( !normalized || normalized.length > MAX_CUSTOM_MODEL_LENGTH || builtInModelSlugs.has(normalized) || seen.has(normalized) ) { continue; } seen.add(normalized); normalizedModels.push(normalized); if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { break; } } return normalizedModels;} function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), };} export function getCustomModelsForProvider( settings: Pick<AppSettings, CustomModelSettingsKey>, provider: ProviderKind,): readonly string[] { return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];} export function getDefaultCustomModelsForProvider( defaults: Pick<AppSettings, CustomModelSettingsKey>, provider: ProviderKind,): readonly string[] { return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];} export function patchCustomModels( provider: ProviderKind, models: string[],): Partial<Pick<AppSettings, CustomModelSettingsKey>> { return { [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, };} export function getCustomModelsByProvider( settings: Pick<AppSettings, CustomModelSettingsKey>,): Record<ProviderKind, readonly string[]> { return { codex: getCustomModelsForProvider(settings, "codex"), claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), };} export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], selectedModel?: string | null,): AppModelOption[] { const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ slug, name, isCustom: false, })); const seen = new Set(options.map((option) => option.slug)); const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); for (const slug of normalizeCustomModelSlugs(customModels, provider)) { if (seen.has(slug)) { continue; } seen.add(slug); options.push({ slug, name: slug, isCustom: true, }); } const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); const selectedModelMatchesExistingName = typeof trimmedSelectedModel === "string" && options.some((option) => option.name.toLowerCase() === trimmedSelectedModel); if ( normalizedSelectedModel && !seen.has(normalizedSelectedModel) && !selectedModelMatchesExistingName ) { options.push({ slug: normalizedSelectedModel, name: normalizedSelectedModel, isCustom: true, }); } return options;} export function resolveAppModelSelection( provider: ProviderKind, customModels: Record<ProviderKind, readonly string[]>, selectedModel: string | null | undefined,): string { const customModelsForProvider = customModels[provider]; const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);} export function getCustomModelOptionsByProvider( settings: Pick<AppSettings, CustomModelSettingsKey>,): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { const customModelsByProvider = getCustomModelsByProvider(settings); return { codex: getAppModelOptions("codex", customModelsByProvider.codex), claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), };} export function getProviderStartOptions( settings: Pick<AppSettings, "claudeBinaryPath" | "codexBinaryPath" | "codexHomePath">,): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { ...(settings.codexBinaryPath || settings.codexHomePath ? { codex: { ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), }, } : {}), ...(settings.claudeBinaryPath ? { claudeAgent: { binaryPath: settings.claudeBinaryPath, }, } : {}), }; return Object.keys(providerOptions).length > 0 ? providerOptions : undefined;} export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, DEFAULT_APP_SETTINGS, AppSettingsSchema, ); const updateSettings = useCallback( (patch: Partial<AppSettings>) => { setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); }, [setSettings], ); const resetSettings = useCallback(() => { setSettings(DEFAULT_APP_SETTINGS); }, [setSettings]); return { settings, updateSettings, resetSettings, defaults: DEFAULT_APP_SETTINGS, } as const;}
import { useCallback } from "react";
import { Option, Schema } from "effect";
import {
TrimmedNonEmptyString,
type ProviderKind,
type ProviderStartOptions,
} from "@t3tools/contracts";
import {
getDefaultModel,
getModelOptions,
normalizeModelSlug,
resolveSelectableModel,
} from "@t3tools/shared/model";
import { useLocalStorage } from "./hooks/useLocalStorage";
import { EnvMode } from "./components/BranchToolbar.logic";
const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const MAX_CUSTOM_MODEL_COUNT = 32;
export const MAX_CUSTOM_MODEL_LENGTH = 256;
export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
export type TimestampFormat = typeof TimestampFormat.Type;
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
export type ProviderCustomModelConfig = {
provider: ProviderKind;
settingsKey: CustomModelSettingsKey;
defaultSettingsKey: CustomModelSettingsKey;
title: string;
description: string;
placeholder: string;
example: string;
};
const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),
};
const withDefaults =
<
S extends Schema.Top & Schema.WithoutConstructorDefault,
D extends S["~type.make.in"] & S["Encoded"],
>(
fallback: () => D,
) =>
(schema: S) =>
schema.pipe(
Schema.withConstructorDefault(() => Option.some(fallback())),
Schema.withDecodingDefault(() => fallback()),
);
export const AppSettingsSchema = Schema.Struct({
claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
textGenerationModel: Schema.optional(TrimmedNonEmptyString),
});
export type AppSettings = typeof AppSettingsSchema.Type;
export interface AppModelOption {
slug: string;
name: string;
isCustom: boolean;
}
const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {
codex: {
provider: "codex",
settingsKey: "customCodexModels",
defaultSettingsKey: "customCodexModels",
title: "Codex",
description: "Save additional Codex model slugs for the picker and `/model` command.",
placeholder: "your-codex-model-slug",
example: "gpt-6.7-codex-ultra-preview",
},
claudeAgent: {
provider: "claudeAgent",
settingsKey: "customClaudeModels",
defaultSettingsKey: "customClaudeModels",
title: "Claude",
description: "Save additional Claude model slugs for the picker and `/model` command.",
placeholder: "your-claude-model-slug",
example: "claude-sonnet-5-0",
},
};
export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
export function normalizeCustomModelSlugs(
models: Iterable<string | null | undefined>,
provider: ProviderKind = "codex",
): string[] {
const normalizedModels: string[] = [];
const seen = new Set<string>();
const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
for (const candidate of models) {
const normalized = normalizeModelSlug(candidate, provider);
if (
!normalized ||
normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
builtInModelSlugs.has(normalized) ||
seen.has(normalized)
) {
continue;
}
seen.add(normalized);
normalizedModels.push(normalized);
if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
break;
}
}
return normalizedModels;
}
function normalizeAppSettings(settings: AppSettings): AppSettings {
return {
...settings,
customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
};
}
export function getCustomModelsForProvider(
settings: Pick<AppSettings, CustomModelSettingsKey>,
provider: ProviderKind,
): readonly string[] {
return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
}
export function getDefaultCustomModelsForProvider(
defaults: Pick<AppSettings, CustomModelSettingsKey>,
provider: ProviderKind,
): readonly string[] {
return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
}
export function patchCustomModels(
provider: ProviderKind,
models: string[],
): Partial<Pick<AppSettings, CustomModelSettingsKey>> {
return {
[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
};
}
export function getCustomModelsByProvider(
settings: Pick<AppSettings, CustomModelSettingsKey>,
): Record<ProviderKind, readonly string[]> {
return {
codex: getCustomModelsForProvider(settings, "codex"),
claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
};
}
export function getAppModelOptions(
provider: ProviderKind,
customModels: readonly string[],
selectedModel?: string | null,
): AppModelOption[] {
const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
slug,
name,
isCustom: false,
}));
const seen = new Set(options.map((option) => option.slug));
const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
if (seen.has(slug)) {
continue;
}
seen.add(slug);
options.push({
slug,
name: slug,
isCustom: true,
});
}
const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
const selectedModelMatchesExistingName =
typeof trimmedSelectedModel === "string" &&
options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
if (
normalizedSelectedModel &&
!seen.has(normalizedSelectedModel) &&
!selectedModelMatchesExistingName
) {
options.push({
slug: normalizedSelectedModel,
name: normalizedSelectedModel,
isCustom: true,
});
}
return options;
}
export function resolveAppModelSelection(
provider: ProviderKind,
customModels: Record<ProviderKind, readonly string[]>,
selectedModel: string | null | undefined,
): string {
const customModelsForProvider = customModels[provider];
const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
}
export function getCustomModelOptionsByProvider(
settings: Pick<AppSettings, CustomModelSettingsKey>,
): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
const customModelsByProvider = getCustomModelsByProvider(settings);
return {
codex: getAppModelOptions("codex", customModelsByProvider.codex),
claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent),
};
}
export function getProviderStartOptions(
settings: Pick<AppSettings, "claudeBinaryPath" | "codexBinaryPath" | "codexHomePath">,
): ProviderStartOptions | undefined {
const providerOptions: ProviderStartOptions = {
...(settings.codexBinaryPath || settings.codexHomePath
? {
codex: {
...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}),
...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}),
},
}
: {}),
...(settings.claudeBinaryPath
? {
claudeAgent: {
binaryPath: settings.claudeBinaryPath,
},
}
: {}),
};
return Object.keys(providerOptions).length > 0 ? providerOptions : undefined;
}
export function useAppSettings() {
const [settings, setSettings] = useLocalStorage(
APP_SETTINGS_STORAGE_KEY,
DEFAULT_APP_SETTINGS,
AppSettingsSchema,
);
const updateSettings = useCallback(
(patch: Partial<AppSettings>) => {
setSettings((prev) => normalizeAppSettings({ ...prev, ...patch }));
},
[setSettings],
);
const resetSettings = useCallback(() => {
setSettings(DEFAULT_APP_SETTINGS);
}, [setSettings]);
return {
settings,
updateSettings,
resetSettings,
defaults: DEFAULT_APP_SETTINGS,
} as const;
}