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
9 changes: 7 additions & 2 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";

import { resolveAttachmentPath } from "../../attachmentStore.ts";
Expand All @@ -17,7 +18,6 @@ import {
TextGeneration,
} from "../Services/TextGeneration.ts";

const CODEX_MODEL = "gpt-5.3-codex";
const CODEX_REASONING_EFFORT = "low";
const CODEX_TIMEOUT_MS = 180_000;

Expand Down Expand Up @@ -187,13 +187,15 @@ const makeCodexTextGeneration = Effect.gen(function* () {
outputSchemaJson,
imagePaths = [],
cleanupPaths = [],
model,
}: {
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
cwd: string;
prompt: string;
outputSchemaJson: S;
imagePaths?: ReadonlyArray<string>;
cleanupPaths?: ReadonlyArray<string>;
model?: string;
}): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
Effect.gen(function* () {
const schemaPath = yield* writeTempFile(
Expand All @@ -212,7 +214,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
"-s",
"read-only",
"--model",
CODEX_MODEL,
model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL,
"--config",
`model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
"--output-schema",
Expand Down Expand Up @@ -353,6 +355,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
cwd: input.cwd,
prompt,
outputSchemaJson,
...(input.model ? { model: input.model } : {}),
}).pipe(
Effect.map(
(generated) =>
Expand Down Expand Up @@ -398,6 +401,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
title: Schema.String,
body: Schema.String,
}),
...(input.model ? { model: input.model } : {}),
}).pipe(
Effect.map(
(generated) =>
Expand Down Expand Up @@ -449,6 +453,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
branch: Schema.String,
}),
imagePaths,
...(input.model ? { model: input.model } : {}),
});

return {
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ export const makeGitManager = Effect.gen(function* () {
/** When true, also produce a semantic feature branch name. */
includeBranch?: boolean;
filePaths?: readonly string[];
model?: string;
}) =>
Effect.gen(function* () {
const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
Expand All @@ -665,6 +666,7 @@ export const makeGitManager = Effect.gen(function* () {
stagedSummary: limitContext(context.stagedSummary, 8_000),
stagedPatch: limitContext(context.stagedPatch, 50_000),
...(input.includeBranch ? { includeBranch: true } : {}),
...(input.model ? { model: input.model } : {}),
})
.pipe(Effect.map((result) => sanitizeCommitMessage(result)));

Expand All @@ -682,6 +684,7 @@ export const makeGitManager = Effect.gen(function* () {
commitMessage?: string,
preResolvedSuggestion?: CommitAndBranchSuggestion,
filePaths?: readonly string[],
model?: string,
) =>
Effect.gen(function* () {
const suggestion =
Expand All @@ -691,6 +694,7 @@ export const makeGitManager = Effect.gen(function* () {
branch,
...(commitMessage ? { commitMessage } : {}),
...(filePaths ? { filePaths } : {}),
...(model ? { model } : {}),
}));
if (!suggestion) {
return { status: "skipped_no_changes" as const };
Expand All @@ -704,7 +708,7 @@ export const makeGitManager = Effect.gen(function* () {
};
});

const runPrStep = (cwd: string, fallbackBranch: string | null) =>
const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) =>
Effect.gen(function* () {
const details = yield* gitCore.statusDetails(cwd);
const branch = details.branch ?? fallbackBranch;
Expand Down Expand Up @@ -748,6 +752,7 @@ export const makeGitManager = Effect.gen(function* () {
commitSummary: limitContext(rangeContext.commitSummary, 20_000),
diffSummary: limitContext(rangeContext.diffSummary, 20_000),
diffPatch: limitContext(rangeContext.diffPatch, 60_000),
...(model ? { model } : {}),
});

const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
Expand Down Expand Up @@ -972,6 +977,7 @@ export const makeGitManager = Effect.gen(function* () {
branch: string | null,
commitMessage?: string,
filePaths?: readonly string[],
model?: string,
) =>
Effect.gen(function* () {
const suggestion = yield* resolveCommitAndBranchSuggestion({
Expand All @@ -980,6 +986,7 @@ export const makeGitManager = Effect.gen(function* () {
...(commitMessage ? { commitMessage } : {}),
...(filePaths ? { filePaths } : {}),
includeBranch: true,
...(model ? { model } : {}),
});
if (!suggestion) {
return yield* gitManagerError(
Expand Down Expand Up @@ -1028,6 +1035,7 @@ export const makeGitManager = Effect.gen(function* () {
initialStatus.branch,
input.commitMessage,
input.filePaths,
input.textGenerationModel,
);
branchStep = result.branchStep;
commitMessageForStep = result.resolvedCommitMessage;
Expand All @@ -1044,14 +1052,15 @@ export const makeGitManager = Effect.gen(function* () {
commitMessageForStep,
preResolvedCommitSuggestion,
input.filePaths,
input.textGenerationModel,
);

const push = wantsPush
? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch)
: { status: "skipped_not_requested" as const };

const pr = wantsPr
? yield* runPrStep(input.cwd, currentBranch)
? yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel)
: { status: "skipped_not_requested" as const };

return {
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/git/Services/TextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface CommitMessageGenerationInput {
stagedPatch: string;
/** When true, the model also returns a semantic branch name for the change. */
includeBranch?: boolean;
/** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
model?: string;
}

export interface CommitMessageGenerationResult {
Expand All @@ -35,6 +37,8 @@ export interface PrContentGenerationInput {
commitSummary: string;
diffSummary: string;
diffPatch: string;
/** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
model?: string;
}

export interface PrContentGenerationResult {
Expand All @@ -46,6 +50,8 @@ export interface BranchNameGenerationInput {
cwd: string;
message: string;
attachments?: ReadonlyArray<ChatAttachment> | undefined;
/** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
model?: string;
}

export interface BranchNameGenerationResult {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type ChatAttachment,
CommandId,
DEFAULT_GIT_TEXT_GENERATION_MODEL,
EventId,
type OrchestrationEvent,
type ProviderModelOptions,
Expand Down Expand Up @@ -391,6 +392,7 @@ const make = Effect.gen(function* () {
cwd,
message: input.messageText,
...(attachments.length > 0 ? { attachments } : {}),
model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
})
.pipe(
Effect.catch((error) =>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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
3 changes: 2 additions & 1 deletion apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { useLocalStorage } from "./hooks/useLocalStorage";

Expand Down Expand Up @@ -34,6 +34,7 @@ const AppSettingsSchema = Schema.Struct({
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
textGenerationModel: Schema.optional(TrimmedNonEmptyString),
});
export type AppSettings = typeof AppSettingsSchema.Type;
export interface AppModelOption {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
resolveQuickAction,
summarizeGitResult,
} from "./GitActionsControl.logic";
import { useAppSettings } from "~/appSettings";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import {
Expand Down Expand Up @@ -154,6 +155,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
}

export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
const { settings } = useAppSettings();
const threadToastData = useMemo(
() => (activeThreadId ? { threadId: activeThreadId } : undefined),
[activeThreadId],
Expand Down Expand Up @@ -191,7 +193,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));

const runImmediateGitActionMutation = useMutation(
gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
gitRunStackedActionMutationOptions({
cwd: gitCwd,
queryClient,
model: settings.textGenerationModel ?? null,
}),
);
const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/gitReactQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export function gitCheckoutMutationOptions(input: {
export function gitRunStackedActionMutationOptions(input: {
cwd: string | null;
queryClient: QueryClient;
model?: string | null;
}) {
return mutationOptions({
mutationKey: gitMutationKeys.runStackedAction(input.cwd),
Expand All @@ -134,6 +135,7 @@ export function gitRunStackedActionMutationOptions(input: {
...(commitMessage ? { commitMessage } : {}),
...(featureBranch ? { featureBranch } : {}),
...(filePaths ? { filePaths } : {}),
...(input.model ? { model: input.model } : {}),
});
},
onSettled: async () => {
Expand Down
74 changes: 72 additions & 2 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { type ProviderKind } from "@t3tools/contracts";
import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
Expand Down Expand Up @@ -112,6 +112,17 @@ function SettingsRouteView() {
const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
const availableEditors = serverConfigQuery.data?.availableEditors;

const gitTextGenerationModelOptions = getAppModelOptions(
"codex",
settings.customCodexModels,
settings.textGenerationModel,
);
const selectedGitTextGenerationModelLabel =
gitTextGenerationModelOptions.find(
(option) =>
option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL),
)?.name ?? settings.textGenerationModel;

const openKeybindingsFile = useCallback(() => {
if (!keybindingsConfigPath) return;
setOpenKeybindingsError(null);
Expand Down Expand Up @@ -502,6 +513,65 @@ function SettingsRouteView() {
</div>
</section>

<section className="rounded-2xl border border-border bg-card p-5">
<div className="mb-4">
<h2 className="text-sm font-medium text-foreground">Git</h2>
<p className="mt-1 text-xs text-muted-foreground">
Configure the model used for generating commit messages, PR titles, and branch
names.
</p>
</div>

<div className="flex flex-col gap-4 rounded-lg border border-border bg-background px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Text generation model</p>
<p className="text-xs text-muted-foreground">
Model used for auto-generated git content.
</p>
</div>
<Select
value={settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL}
onValueChange={(value) => {
if (value) {
updateSettings({
textGenerationModel: value,
});
}
}}
>
<SelectTrigger
className="w-full shrink-0 sm:w-48"
aria-label="Git text generation model"
>
<SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>
</SelectTrigger>
<SelectPopup align="end">
{gitTextGenerationModelOptions.map((option) => (
<SelectItem key={option.slug} value={option.slug}>
{option.name}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>

{settings.textGenerationModel !== defaults.textGenerationModel ? (
<div className="mt-3 flex justify-end">
<Button
size="xs"
variant="outline"
onClick={() =>
updateSettings({
textGenerationModel: defaults.textGenerationModel,
})
}
>
Restore default
</Button>
</div>
) : null}
</section>

<section className="rounded-2xl border border-border bg-card p-5">
<div className="mb-4">
<h2 className="text-sm font-medium text-foreground">Threads</h2>
Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/git.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Schema } from "effect";
import { Option, Schema } from "effect";
import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model";

const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;

Expand Down Expand Up @@ -64,6 +65,9 @@ export const GitRunStackedActionInput = Schema.Struct({
filePaths: Schema.optional(
Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
),
textGenerationModel: Schema.optional(TrimmedNonEmptyStringSchema).pipe(
Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL)),
),
});
export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;

Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type ModelOption = {
export const MODEL_OPTIONS_BY_PROVIDER = {
codex: [
{ slug: "gpt-5.4", name: "GPT-5.4" },
{ slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
{ slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
Expand All @@ -38,6 +39,8 @@ export const DEFAULT_MODEL_BY_PROVIDER = {
codex: "gpt-5.4",
} as const satisfies Record<ProviderKind, ModelSlug>;

export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const;

export const MODEL_SLUG_ALIASES_BY_PROVIDER = {
codex: {
"5.4": "gpt-5.4",
Expand Down