History

feat(git): Add Claude as a TextGenerationProvider (#1323)

Dean Chérif committed 7 months ago

Commit ff8d87d1 parent 3289a2534 files · +1920 −605Browse files

Unified · Split

34 files
  1. apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +248 −0
  2. apps/server/src/git/Layers/ClaudeTextGeneration.ts +301 −0
  3. apps/server/src/git/Layers/CodexTextGeneration.test.ts +176 −47
  4. apps/server/src/git/Layers/CodexTextGeneration.ts +139 −249
  5. apps/server/src/git/Layers/GitManager.test.ts +6 −0
  6. apps/server/src/git/Layers/GitManager.ts +12 −12
  7. apps/server/src/git/Layers/RoutingTextGeneration.ts +72 −0
  8. apps/server/src/git/Prompts.test.ts +150 −0
  9. apps/server/src/git/Prompts.ts +153 −0
  10. apps/server/src/git/Services/TextGeneration.ts +10 −7
  11. apps/server/src/git/Utils.ts +102 −0
  12. apps/server/src/git/isRepo.ts +0 −6
  13. apps/server/src/orchestration/Layers/CheckpointReactor.ts +1 −1
  14. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +5 −2
  15. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +1 −1
  16. apps/server/src/serverLayers.ts +2 −2
  17. apps/server/src/wsServer.test.ts +12 −0
  18. apps/web/src/appSettings.test.ts +60 −2
  19. apps/web/src/appSettings.ts +9 −178
  20. apps/web/src/components/ChatView.tsx +4 −5
  21. apps/web/src/components/GitActionsControl.tsx +1 −1
  22. apps/web/src/components/chat/ProviderModelPicker.browser.tsx +22 −0
  23. apps/web/src/components/chat/ProviderModelPicker.tsx +6 −2
  24. apps/web/src/components/chat/TraitsPicker.browser.tsx +21 −1
  25. apps/web/src/components/chat/TraitsPicker.tsx +55 −29
  26. apps/web/src/composerDraftStore.ts +1 −1
  27. apps/web/src/lib/gitReactQuery.test.ts +8 −1
  28. apps/web/src/lib/gitReactQuery.ts +3 −3
  29. apps/web/src/modelSelection.ts +228 −0
  30. apps/web/src/routes/_chat.settings.tsx +66 −47
  31. apps/web/src/wsNativeApi.test.ts +18 −2
  32. packages/contracts/src/git.test.ts +19 −0
  33. packages/contracts/src/git.ts +3 −5
  34. packages/contracts/src/model.ts +6 −1
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+248−0
@@ -0,0 +1,248 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { it } from "@effect/vitest";
3+import { Effect, FileSystem, Layer, Path } from "effect";
4+import { expect } from "vitest";
5+
6+import { ServerConfig } from "../../config.ts";
7+import { TextGeneration } from "../Services/TextGeneration.ts";
8+import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
9+
10+const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe(
11+ Layer.provideMerge(
12+ ServerConfig.layerTest(process.cwd(), {
13+ prefix: "t3code-claude-text-generation-test-",
14+ }),
15+ ),
16+ Layer.provideMerge(NodeServices.layer),
17+);
18+
19+function makeFakeClaudeBinary(dir: string) {
20+ return Effect.gen(function* () {
21+ const fs = yield* FileSystem.FileSystem;
22+ const path = yield* Path.Path;
23+ const binDir = path.join(dir, "bin");
24+ const claudePath = path.join(binDir, "claude");
25+ yield* fs.makeDirectory(binDir, { recursive: true });
26+
27+ yield* fs.writeFileString(
28+ claudePath,
29+ [
30+ "#!/bin/sh",
31+ 'args="$*"',
32+ 'stdin_content="$(cat)"',
33+ 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then',
34+ ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {',
35+ ' printf "%s\\n" "args missing expected content" >&2',
36+ " exit 2",
37+ " }",
38+ "fi",
39+ 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then',
40+ ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then',
41+ ' printf "%s\\n" "args contained forbidden content" >&2',
42+ " exit 3",
43+ " fi",
44+ "fi",
45+ 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then',
46+ ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {',
47+ ' printf "%s\\n" "stdin missing expected content" >&2',
48+ " exit 4",
49+ " }",
50+ "fi",
51+ 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then',
52+ ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2',
53+ "fi",
54+ 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"',
55+ 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"',
56+ "",
57+ ].join("\n"),
58+ );
59+ yield* fs.chmod(claudePath, 0o755);
60+ return binDir;
61+ });
62+}
63+
64+function withFakeClaudeEnv<A, E, R>(
65+ input: {
66+ output: string;
67+ exitCode?: number;
68+ stderr?: string;
69+ argsMustContain?: string;
70+ argsMustNotContain?: string;
71+ stdinMustContain?: string;
72+ },
73+ effect: Effect.Effect<A, E, R>,
74+) {
75+ return Effect.acquireUseRelease(
76+ Effect.gen(function* () {
77+ const fs = yield* FileSystem.FileSystem;
78+ const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });
79+ const binDir = yield* makeFakeClaudeBinary(tempDir);
80+ const previousPath = process.env.PATH;
81+ const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
82+ const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
83+ const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
84+ const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
85+ const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
86+ const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
87+
88+ yield* Effect.sync(() => {
89+ process.env.PATH = `${binDir}:${previousPath ?? ""}`;
90+ process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;
91+
92+ if (input.exitCode !== undefined) {
93+ process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode);
94+ } else {
95+ delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
96+ }
97+
98+ if (input.stderr !== undefined) {
99+ process.env.T3_FAKE_CLAUDE_STDERR = input.stderr;
100+ } else {
101+ delete process.env.T3_FAKE_CLAUDE_STDERR;
102+ }
103+
104+ if (input.argsMustContain !== undefined) {
105+ process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain;
106+ } else {
107+ delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
108+ }
109+
110+ if (input.argsMustNotContain !== undefined) {
111+ process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain;
112+ } else {
113+ delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
114+ }
115+
116+ if (input.stdinMustContain !== undefined) {
117+ process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain;
118+ } else {
119+ delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
120+ }
121+ });
122+
123+ return {
124+ previousPath,
125+ previousOutput,
126+ previousExitCode,
127+ previousStderr,
128+ previousArgsMustContain,
129+ previousArgsMustNotContain,
130+ previousStdinMustContain,
131+ };
132+ }),
133+ () => effect,
134+ (previous) =>
135+ Effect.sync(() => {
136+ process.env.PATH = previous.previousPath;
137+
138+ if (previous.previousOutput === undefined) {
139+ delete process.env.T3_FAKE_CLAUDE_OUTPUT;
140+ } else {
141+ process.env.T3_FAKE_CLAUDE_OUTPUT = previous.previousOutput;
142+ }
143+
144+ if (previous.previousExitCode === undefined) {
145+ delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
146+ } else {
147+ process.env.T3_FAKE_CLAUDE_EXIT_CODE = previous.previousExitCode;
148+ }
149+
150+ if (previous.previousStderr === undefined) {
151+ delete process.env.T3_FAKE_CLAUDE_STDERR;
152+ } else {
153+ process.env.T3_FAKE_CLAUDE_STDERR = previous.previousStderr;
154+ }
155+
156+ if (previous.previousArgsMustContain === undefined) {
157+ delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
158+ } else {
159+ process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previous.previousArgsMustContain;
160+ }
161+
162+ if (previous.previousArgsMustNotContain === undefined) {
163+ delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
164+ } else {
165+ process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previous.previousArgsMustNotContain;
166+ }
167+
168+ if (previous.previousStdinMustContain === undefined) {
169+ delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
170+ } else {
171+ process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previous.previousStdinMustContain;
172+ }
173+ }),
174+ );
175+}
176+
177+it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
178+ it.effect("forwards Claude thinking settings for Haiku without passing effort", () =>
179+ withFakeClaudeEnv(
180+ {
181+ output: JSON.stringify({
182+ structured_output: {
183+ subject: "Add important change",
184+ body: "",
185+ },
186+ }),
187+ argsMustContain: '--settings {"alwaysThinkingEnabled":false}',
188+ argsMustNotContain: "--effort",
189+ },
190+ Effect.gen(function* () {
191+ const textGeneration = yield* TextGeneration;
192+
193+ const generated = yield* textGeneration.generateCommitMessage({
194+ cwd: process.cwd(),
195+ branch: "feature/claude-effect",
196+ stagedSummary: "M README.md",
197+ stagedPatch: "diff --git a/README.md b/README.md",
198+ modelSelection: {
199+ provider: "claudeAgent",
200+ model: "claude-haiku-4-5",
201+ options: {
202+ thinking: false,
203+ effort: "high",
204+ },
205+ },
206+ });
207+
208+ expect(generated.subject).toBe("Add important change");
209+ }),
210+ ),
211+ );
212+
213+ it.effect("forwards Claude fast mode and supported effort", () =>
214+ withFakeClaudeEnv(
215+ {
216+ output: JSON.stringify({
217+ structured_output: {
218+ title: "Improve orchestration flow",
219+ body: "Body",
220+ },
221+ }),
222+ argsMustContain: '--effort max --settings {"fastMode":true}',
223+ },
224+ Effect.gen(function* () {
225+ const textGeneration = yield* TextGeneration;
226+
227+ const generated = yield* textGeneration.generatePrContent({
228+ cwd: process.cwd(),
229+ baseBranch: "main",
230+ headBranch: "feature/claude-effect",
231+ commitSummary: "Improve orchestration",
232+ diffSummary: "1 file changed",
233+ diffPatch: "diff --git a/README.md b/README.md",
234+ modelSelection: {
235+ provider: "claudeAgent",
236+ model: "claude-opus-4-6",
237+ options: {
238+ effort: "max",
239+ fastMode: true,
240+ },
241+ },
242+ });
243+
244+ expect(generated.title).toBe("Improve orchestration flow");
245+ }),
246+ ),
247+ );
248+});
apps/server/src/git/Layers/ClaudeTextGeneration.ts+301−0
@@ -0,0 +1,301 @@
1+/**
2+ * ClaudeTextGeneration – Text generation layer using the Claude CLI.
3+ *
4+ * Implements the same TextGenerationShape contract as CodexTextGeneration but
5+ * delegates to the `claude` CLI (`claude -p`) with structured JSON output
6+ * instead of the `codex exec` CLI.
7+ *
8+ * @module ClaudeTextGeneration
9+ */
10+import { Effect, Layer, Option, Schema, Stream } from "effect";
11+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12+
13+import { ClaudeModelSelection } from "@t3tools/contracts";
14+import { normalizeClaudeModelOptions } from "@t3tools/shared/model";
15+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
16+
17+import { TextGenerationError } from "../Errors.ts";
18+import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
19+import {
20+ buildBranchNamePrompt,
21+ buildCommitMessagePrompt,
22+ buildPrContentPrompt,
23+} from "../Prompts.ts";
24+import {
25+ normalizeCliError,
26+ sanitizeCommitSubject,
27+ sanitizePrTitle,
28+ toJsonSchemaObject,
29+} from "../Utils.ts";
30+
31+const CLAUDE_TIMEOUT_MS = 180_000;
32+
33+/**
34+ * Schema for the wrapper JSON returned by `claude -p --output-format json`.
35+ * We only care about `structured_output`.
36+ */
37+const ClaudeOutputEnvelope = Schema.Struct({
38+ structured_output: Schema.Unknown,
39+});
40+
41+const makeClaudeTextGeneration = Effect.gen(function* () {
42+ const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
43+
44+ const readStreamAsString = <E>(
45+ operation: string,
46+ stream: Stream.Stream<Uint8Array, E>,
47+ ): Effect.Effect<string, TextGenerationError> =>
48+ stream.pipe(
49+ Stream.decodeText(),
50+ Stream.runFold(
51+ () => "",
52+ (acc, chunk) => acc + chunk,
53+ ),
54+ Effect.mapError((cause) =>
55+ normalizeCliError("claude", operation, cause, "Failed to collect process output"),
56+ ),
57+ );
58+
59+ /**
60+ * Spawn the Claude CLI with structured JSON output and return the parsed,
61+ * schema-validated result.
62+ */
63+ const runClaudeJson = <S extends Schema.Top>({
64+ operation,
65+ cwd,
66+ prompt,
67+ outputSchemaJson,
68+ modelSelection,
69+ }: {
70+ operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
71+ cwd: string;
72+ prompt: string;
73+ outputSchemaJson: S;
74+ modelSelection: ClaudeModelSelection;
75+ }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
76+ Effect.gen(function* () {
77+ const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
78+ const normalizedOptions = normalizeClaudeModelOptions(
79+ modelSelection.model,
80+ modelSelection.options,
81+ );
82+ const settings = {
83+ ...(typeof normalizedOptions?.thinking === "boolean"
84+ ? { alwaysThinkingEnabled: normalizedOptions.thinking }
85+ : {}),
86+ ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),
87+ };
88+
89+ const runClaudeCommand = Effect.gen(function* () {
90+ const command = ChildProcess.make(
91+ "claude",
92+ [
93+ "-p",
94+ "--output-format",
95+ "json",
96+ "--json-schema",
97+ jsonSchemaStr,
98+ "--model",
99+ modelSelection.model,
100+ ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
101+ ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
102+ "--dangerously-skip-permissions",
103+ ],
104+ {
105+ cwd,
106+ shell: process.platform === "win32",
107+ stdin: {
108+ stream: Stream.encodeText(Stream.make(prompt)),
109+ },
110+ },
111+ );
112+
113+ const child = yield* commandSpawner
114+ .spawn(command)
115+ .pipe(
116+ Effect.mapError((cause) =>
117+ normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"),
118+ ),
119+ );
120+
121+ const [stdout, stderr, exitCode] = yield* Effect.all(
122+ [
123+ readStreamAsString(operation, child.stdout),
124+ readStreamAsString(operation, child.stderr),
125+ child.exitCode.pipe(
126+ Effect.mapError((cause) =>
127+ normalizeCliError(
128+ "claude",
129+ operation,
130+ cause,
131+ "Failed to read Claude CLI exit code",
132+ ),
133+ ),
134+ ),
135+ ],
136+ { concurrency: "unbounded" },
137+ );
138+
139+ if (exitCode !== 0) {
140+ const stderrDetail = stderr.trim();
141+ const stdoutDetail = stdout.trim();
142+ const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
143+ return yield* new TextGenerationError({
144+ operation,
145+ detail:
146+ detail.length > 0
147+ ? `Claude CLI command failed: ${detail}`
148+ : `Claude CLI command failed with code ${exitCode}.`,
149+ });
150+ }
151+
152+ return stdout;
153+ });
154+
155+ const rawStdout = yield* runClaudeCommand.pipe(
156+ Effect.scoped,
157+ Effect.timeoutOption(CLAUDE_TIMEOUT_MS),
158+ Effect.flatMap(
159+ Option.match({
160+ onNone: () =>
161+ Effect.fail(
162+ new TextGenerationError({ operation, detail: "Claude CLI request timed out." }),
163+ ),
164+ onSome: (value) => Effect.succeed(value),
165+ }),
166+ ),
167+ );
168+
169+ const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))(
170+ rawStdout,
171+ ).pipe(
172+ Effect.catchTag("SchemaError", (cause) =>
173+ Effect.fail(
174+ new TextGenerationError({
175+ operation,
176+ detail: "Claude CLI returned unexpected output format.",
177+ cause,
178+ }),
179+ ),
180+ ),
181+ );
182+
183+ return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe(
184+ Effect.catchTag("SchemaError", (cause) =>
185+ Effect.fail(
186+ new TextGenerationError({
187+ operation,
188+ detail: "Claude returned invalid structured output.",
189+ cause,
190+ }),
191+ ),
192+ ),
193+ );
194+ });
195+
196+ // ---------------------------------------------------------------------------
197+ // TextGenerationShape methods
198+ // ---------------------------------------------------------------------------
199+
200+ const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
201+ "ClaudeTextGeneration.generateCommitMessage",
202+ )(function* (input) {
203+ const { prompt, outputSchema } = buildCommitMessagePrompt({
204+ branch: input.branch,
205+ stagedSummary: input.stagedSummary,
206+ stagedPatch: input.stagedPatch,
207+ includeBranch: input.includeBranch === true,
208+ });
209+
210+ if (input.modelSelection.provider !== "claudeAgent") {
211+ return yield* new TextGenerationError({
212+ operation: "generateCommitMessage",
213+ detail: "Invalid model selection.",
214+ });
215+ }
216+
217+ const generated = yield* runClaudeJson({
218+ operation: "generateCommitMessage",
219+ cwd: input.cwd,
220+ prompt,
221+ outputSchemaJson: outputSchema,
222+ modelSelection: input.modelSelection,
223+ });
224+
225+ return {
226+ subject: sanitizeCommitSubject(generated.subject),
227+ body: generated.body.trim(),
228+ ...("branch" in generated && typeof generated.branch === "string"
229+ ? { branch: sanitizeFeatureBranchName(generated.branch) }
230+ : {}),
231+ };
232+ });
233+
234+ const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
235+ "ClaudeTextGeneration.generatePrContent",
236+ )(function* (input) {
237+ const { prompt, outputSchema } = buildPrContentPrompt({
238+ baseBranch: input.baseBranch,
239+ headBranch: input.headBranch,
240+ commitSummary: input.commitSummary,
241+ diffSummary: input.diffSummary,
242+ diffPatch: input.diffPatch,
243+ });
244+
245+ if (input.modelSelection.provider !== "claudeAgent") {
246+ return yield* new TextGenerationError({
247+ operation: "generatePrContent",
248+ detail: "Invalid model selection.",
249+ });
250+ }
251+
252+ const generated = yield* runClaudeJson({
253+ operation: "generatePrContent",
254+ cwd: input.cwd,
255+ prompt,
256+ outputSchemaJson: outputSchema,
257+ modelSelection: input.modelSelection,
258+ });
259+
260+ return {
261+ title: sanitizePrTitle(generated.title),
262+ body: generated.body.trim(),
263+ };
264+ });
265+
266+ const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
267+ "ClaudeTextGeneration.generateBranchName",
268+ )(function* (input) {
269+ const { prompt, outputSchema } = buildBranchNamePrompt({
270+ message: input.message,
271+ attachments: input.attachments,
272+ });
273+
274+ if (input.modelSelection.provider !== "claudeAgent") {
275+ return yield* new TextGenerationError({
276+ operation: "generateBranchName",
277+ detail: "Invalid model selection.",
278+ });
279+ }
280+
281+ const generated = yield* runClaudeJson({
282+ operation: "generateBranchName",
283+ cwd: input.cwd,
284+ prompt,
285+ outputSchemaJson: outputSchema,
286+ modelSelection: input.modelSelection,
287+ });
288+
289+ return {
290+ branch: sanitizeBranchFragment(generated.branch),
291+ };
292+ });
293+
294+ return {
295+ generateCommitMessage,
296+ generatePrContent,
297+ generateBranchName,
298+ } satisfies TextGenerationShape;
299+});
300+
301+export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration);
apps/server/src/git/Layers/CodexTextGeneration.test.ts+176−47
@@ -1,6 +1,6 @@
11 import * as NodeServices from "@effect/platform-node/NodeServices";
22 import { it } from "@effect/vitest";
3−import { Effect, FileSystem, Layer, Path } from "effect";
3+import { Effect, FileSystem, Layer, Path, Result } from "effect";
44 import { expect } from "vitest";
55
66 import { ServerConfig } from "../../config.ts";
@@ -8,6 +8,11 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; · 1 unmodified line
88 import { TextGenerationError } from "../Errors.ts";
99 import { TextGeneration } from "../Services/TextGeneration.ts";
1010
11+const DEFAULT_TEST_MODEL_SELECTION = {
12+ provider: "codex" as const,
13+ model: "gpt-5.4-mini",
14+};
15+
1116 const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
1217 Layer.provideMerge(
1318 ServerConfig.layerTest(process.cwd(), {
@@ -38,6 +43,18 @@ function makeFakeCodexBinary(dir: string) { · 24 unmodified lines
3843 " fi",
3944 " continue",
4045 " fi",
46+ ' if [ "$1" = "--config" ]; then',
47+ " shift",
48+ ' if [ "$1" = "service_tier=\\"fast\\"" ]; then',
49+ ' seen_fast_service_tier="1"',
50+ " fi",
51+ ' case "$1" in',
52+ " model_reasoning_effort=*)",
53+ ' seen_reasoning_effort="$1"',
54+ " ;;",
55+ " esac",
56+ " continue",
57+ " fi",
4158 ' if [ "$1" = "--output-last-message" ]; then',
4259 " shift",
4360 ' output_path="$1"',
@@ -49,6 +66,18 @@ function makeFakeCodexBinary(dir: string) { · 5 unmodified lines
4966 ' printf "%s\\n" "missing --image input" >&2',
5067 " exit 2",
5168 "fi",
69+ 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then',
70+ ' printf "%s\\n" "missing fast service tier config" >&2',
71+ " exit 5",
72+ "fi",
73+ 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then',
74+ ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2',
75+ " exit 6",
76+ "fi",
77+ 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then',
78+ ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2',
79+ " exit 7",
80+ "fi",
5281 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then',
5382 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {',
5483 ' printf "%s\\n" "stdin missing expected content" >&2',
@@ -82,6 +111,9 @@ function withFakeCodexEnv<A, E, R>( · 27 unmodified lines
82111 exitCode?: number;
83112 stderr?: string;
84113 requireImage?: boolean;
114+ requireFastServiceTier?: boolean;
115+ requireReasoningEffort?: string;
116+ forbidReasoningEffort?: boolean;
85117 stdinMustContain?: string;
86118 stdinMustNotContain?: string;
87119 },
@@ -97,6 +129,9 @@ function withFakeCodexEnv<A, E, R>( · 9 unmodified lines
97129 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
98130 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
99131 const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
132+ const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
133+ const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
134+ const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
100135 const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
101136 const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
102137
@@ -122,6 +157,24 @@ function withFakeCodexEnv<A, E, R>( · 19 unmodified lines
122157 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
123158 }
124159
160+ if (input.requireFastServiceTier) {
161+ process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = "1";
162+ } else {
163+ delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
164+ }
165+
166+ if (input.requireReasoningEffort !== undefined) {
167+ process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = input.requireReasoningEffort;
168+ } else {
169+ delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
170+ }
171+
172+ if (input.forbidReasoningEffort) {
173+ process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = "1";
174+ } else {
175+ delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
176+ }
177+
125178 if (input.stdinMustContain !== undefined) {
126179 process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain;
127180 } else {
@@ -141,6 +194,9 @@ function withFakeCodexEnv<A, E, R>( · 13 unmodified lines
141194 previousExitCode,
142195 previousStderr,
143196 previousRequireImage,
197+ previousRequireFastServiceTier,
198+ previousRequireReasoningEffort,
199+ previousForbidReasoningEffort,
144200 previousStdinMustContain,
145201 previousStdinMustNotContain,
146202 };
@@ -174,6 +230,27 @@ function withFakeCodexEnv<A, E, R>( · 27 unmodified lines
174230 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
175231 }
176232
233+ if (previous.previousRequireFastServiceTier === undefined) {
234+ delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER;
235+ } else {
236+ process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER =
237+ previous.previousRequireFastServiceTier;
238+ }
239+
240+ if (previous.previousRequireReasoningEffort === undefined) {
241+ delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT;
242+ } else {
243+ process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT =
244+ previous.previousRequireReasoningEffort;
245+ }
246+
247+ if (previous.previousForbidReasoningEffort === undefined) {
248+ delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT;
249+ } else {
250+ process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT =
251+ previous.previousForbidReasoningEffort;
252+ }
253+
177254 if (previous.previousStdinMustContain === undefined) {
178255 delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
179256 } else {
@@ -208,6 +285,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 28 unmodified lines
208285 branch: "feature/codex-effect",
209286 stagedSummary: "M README.md",
210287 stagedPatch: "diff --git a/README.md b/README.md",
288+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
211289 });
212290
213291 expect(generated.subject.length).toBeLessThanOrEqual(72);
@@ -218,6 +296,63 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 4 unmodified lines
218296 ),
219297 );
220298
299+ it.effect(
300+ "forwards codex fast mode and non-default reasoning effort into codex exec config",
301+ () =>
302+ withFakeCodexEnv(
303+ {
304+ output: JSON.stringify({
305+ subject: "Add important change",
306+ body: "",
307+ }),
308+ requireFastServiceTier: true,
309+ requireReasoningEffort: "xhigh",
310+ stdinMustNotContain: "branch must be a short semantic git branch fragment",
311+ },
312+ Effect.gen(function* () {
313+ const textGeneration = yield* TextGeneration;
314+
315+ yield* textGeneration.generateCommitMessage({
316+ cwd: process.cwd(),
317+ branch: "feature/codex-effect",
318+ stagedSummary: "M README.md",
319+ stagedPatch: "diff --git a/README.md b/README.md",
320+ modelSelection: {
321+ provider: "codex",
322+ model: "gpt-5.4",
323+ options: {
324+ reasoningEffort: "xhigh",
325+ fastMode: true,
326+ },
327+ },
328+ });
329+ }),
330+ ),
331+ );
332+
333+ it.effect("defaults git text generation codex effort to low", () =>
334+ withFakeCodexEnv(
335+ {
336+ output: JSON.stringify({
337+ subject: "Add important change",
338+ body: "",
339+ }),
340+ requireReasoningEffort: "low",
341+ },
342+ Effect.gen(function* () {
343+ const textGeneration = yield* TextGeneration;
344+
345+ yield* textGeneration.generateCommitMessage({
346+ cwd: process.cwd(),
347+ branch: "feature/codex-effect",
348+ stagedSummary: "M README.md",
349+ stagedPatch: "diff --git a/README.md b/README.md",
350+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
351+ });
352+ }),
353+ ),
354+ );
355+
221356 it.effect("generates commit message with branch when includeBranch is true", () =>
222357 withFakeCodexEnv(
223358 {
@@ -237,6 +372,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 13 unmodified lines
237372 stagedSummary: "M README.md",
238373 stagedPatch: "diff --git a/README.md b/README.md",
239374 includeBranch: true,
375+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
240376 });
241377
242378 expect(generated.subject).toBe("Add important change");
@@ -263,6 +399,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 20 unmodified lines
263399 commitSummary: "feat: improve orchestration flow",
264400 diffSummary: "2 files changed",
265401 diffPatch: "diff --git a/a.ts b/a.ts",
402+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
266403 });
267404
268405 expect(generated.title).toBe("Improve orchestration flow");
@@ -286,6 +423,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 17 unmodified lines
286423 const generated = yield* textGeneration.generateBranchName({
287424 cwd: process.cwd(),
288425 message: "Please update session handling.",
426+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
289427 });
290428
291429 expect(generated.branch).toBe("feat/session");
@@ -307,6 +445,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 15 unmodified lines
307445 const generated = yield* textGeneration.generateBranchName({
308446 cwd: process.cwd(),
309447 message: "Fix timeout behavior.",
448+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
310449 });
311450
312451 expect(generated.branch).toBe("fix/session-timeout");
@@ -333,21 +472,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 20 unmodified lines
333472 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
334473
335474 const textGeneration = yield* TextGeneration;
336− const generated = yield* textGeneration
337− .generateBranchName({
338− cwd: process.cwd(),
339− message: "Fix layout bug from screenshot.",
340− attachments: [
341− {
342− type: "image",
343− id: attachmentId,
344− name: "bug.png",
345− mimeType: "image/png",
346− sizeBytes: 5,
347− },
348− ],
349− })
350− .pipe(Effect.ensuring(fs.remove(attachmentPath).pipe(Effect.catch(() => Effect.void))));
475+ const generated = yield* textGeneration.generateBranchName({
476+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
477+ cwd: process.cwd(),
478+ message: "Fix layout bug from screenshot.",
479+ attachments: [
480+ {
481+ type: "image",
482+ id: attachmentId,
483+ name: "bug.png",
484+ mimeType: "image/png",
485+ sizeBytes: 5,
486+ },
487+ ],
488+ });
351489
352490 expect(generated.branch).toBe("fix/ui-regression");
353491 }),
@@ -374,6 +512,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 20 unmodified lines
374512 const textGeneration = yield* TextGeneration;
375513 const generated = yield* textGeneration
376514 .generateBranchName({
515+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
377516 cwd: process.cwd(),
378517 message: "Fix layout bug from screenshot.",
379518 attachments: [
@@ -421,6 +560,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 41 unmodified lines
421560 const textGeneration = yield* TextGeneration;
422561 const result = yield* textGeneration
423562 .generateBranchName({
563+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
424564 cwd: process.cwd(),
425565 message: "Fix layout bug from screenshot.",
426566 attachments: [
@@ -433,17 +573,12 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 6 unmodified lines
433573 },
434574 ],
435575 })
436− .pipe(
437− Effect.match({
438− onFailure: (error) => ({ _tag: "Left" as const, left: error }),
439− onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
440− }),
441− );
576+ .pipe(Effect.result);
442577
443− expect(result._tag).toBe("Left");
444− if (result._tag === "Left") {
445− expect(result.left).toBeInstanceOf(TextGenerationError);
446− expect(result.left.message).toContain("missing --image input");
578+ expect(Result.isFailure(result)).toBe(true);
579+ if (Result.isFailure(result)) {
580+ expect(result.failure).toBeInstanceOf(TextGenerationError);
581+ expect(result.failure.message).toContain("missing --image input");
447582 }
448583 }),
449584 ),
@@ -465,18 +600,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 15 unmodified lines
465600 .generateBranchName({
466601 cwd: process.cwd(),
467602 message: "Fix websocket reconnect flake",
603+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
468604 })
469− .pipe(
470− Effect.match({
471− onFailure: (error) => ({ _tag: "Left" as const, left: error }),
472− onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
473− }),
474− );
475−
476− expect(result._tag).toBe("Left");
477− if (result._tag === "Left") {
478− expect(result.left).toBeInstanceOf(TextGenerationError);
479− expect(result.left.message).toContain("Codex returned invalid structured output");
605+ .pipe(Effect.result);
606+
607+ expect(Result.isFailure(result)).toBe(true);
608+ if (Result.isFailure(result)) {
609+ expect(result.failure).toBeInstanceOf(TextGenerationError);
610+ expect(result.failure.message).toContain("Codex returned invalid structured output");
480611 }
481612 }),
482613 ),
@@ -498,18 +629,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 15 unmodified lines
498629 branch: "feature/codex-error",
499630 stagedSummary: "M README.md",
500631 stagedPatch: "diff --git a/README.md b/README.md",
632+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
501633 })
502− .pipe(
503− Effect.match({
504− onFailure: (error) => ({ _tag: "Left" as const, left: error }),
505− onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
506− }),
507− );
634+ .pipe(Effect.result);
508635
509− expect(result._tag).toBe("Left");
510− if (result._tag === "Left") {
511− expect(result.left).toBeInstanceOf(TextGenerationError);
512− expect(result.left.message).toContain("Codex CLI command failed: codex execution failed");
636+ expect(Result.isFailure(result)).toBe(true);
637+ if (Result.isFailure(result)) {
638+ expect(result.failure).toBeInstanceOf(TextGenerationError);
639+ expect(result.failure.message).toContain(
640+ "Codex CLI command failed: codex execution failed",
641+ );
513642 }
514643 }),
515644 ),
apps/server/src/git/Layers/CodexTextGeneration.ts+139−249
@@ -1,9 +1,10 @@
11 import { randomUUID } from "node:crypto";
22
3−import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
3+import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect";
44 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
6−import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
6+import { CodexModelSelection } from "@t3tools/contracts";
7+import { normalizeCodexModelOptions } from "@t3tools/shared/model";
78 import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
89
910 import { resolveAttachmentPath } from "../../attachmentStore.ts";
@@ -11,90 +12,24 @@ import { ServerConfig } from "../../config.ts"; · 1 unmodified line
1112 import { TextGenerationError } from "../Errors.ts";
1213 import {
1314 type BranchNameGenerationInput,
14− type BranchNameGenerationResult,
15− type CommitMessageGenerationResult,
16− type PrContentGenerationResult,
1715 type TextGenerationShape,
1816 TextGeneration,
1917 } from "../Services/TextGeneration.ts";
18+import {
19+ buildBranchNamePrompt,
20+ buildCommitMessagePrompt,
21+ buildPrContentPrompt,
22+} from "../Prompts.ts";
23+import {
24+ normalizeCliError,
25+ sanitizeCommitSubject,
26+ sanitizePrTitle,
27+ toJsonSchemaObject,
28+} from "../Utils.ts";
2029
21−const CODEX_REASONING_EFFORT = "low";
30+const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
2231 const CODEX_TIMEOUT_MS = 180_000;
2332
24−function toCodexOutputJsonSchema(schema: Schema.Top): unknown {
25− const document = Schema.toJsonSchemaDocument(schema);
26− if (document.definitions && Object.keys(document.definitions).length > 0) {
27− return {
28− ...document.schema,
29− $defs: document.definitions,
30− };
31− }
32− return document.schema;
33−}
34−
35−function normalizeCodexError(
36− operation: string,
37− error: unknown,
38− fallback: string,
39−): TextGenerationError {
40− if (Schema.is(TextGenerationError)(error)) {
41− return error;
42− }
43−
44− if (error instanceof Error) {
45− const lower = error.message.toLowerCase();
46− if (
47− error.message.includes("Command not found: codex") ||
48− lower.includes("spawn codex") ||
49− lower.includes("enoent")
50− ) {
51− return new TextGenerationError({
52− operation,
53− detail: "Codex CLI (`codex`) is required but not available on PATH.",
54− cause: error,
55− });
56− }
57− return new TextGenerationError({
58− operation,
59− detail: `${fallback}: ${error.message}`,
60− cause: error,
61− });
62− }
63−
64− return new TextGenerationError({
65− operation,
66− detail: fallback,
67− cause: error,
68− });
69−}
70−
71−function limitSection(value: string, maxChars: number): string {
72− if (value.length <= maxChars) return value;
73− const truncated = value.slice(0, maxChars);
74− return `${truncated}\n\n[truncated]`;
75−}
76−
77−function sanitizeCommitSubject(raw: string): string {
78− const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
79− const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
80− if (withoutTrailingPeriod.length === 0) {
81− return "Update project files";
82− }
83−
84− if (withoutTrailingPeriod.length <= 72) {
85− return withoutTrailingPeriod;
86− }
87− return withoutTrailingPeriod.slice(0, 72).trimEnd();
88−}
89−
90−function sanitizePrTitle(raw: string): string {
91− const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
92− if (singleLine.length > 0) {
93− return singleLine;
94− }
95− return "Update project changes";
96−}
97−
9833 const makeCodexTextGeneration = Effect.gen(function* () {
9934 const fileSystem = yield* FileSystem.FileSystem;
10035 const path = yield* Path.Path;
@@ -109,39 +44,37 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 8 unmodified lines
10944 operation: string,
11045 stream: Stream.Stream<Uint8Array, E>,
11146 ): Effect.Effect<string, TextGenerationError> =>
112− Effect.gen(function* () {
113− let text = "";
114− yield* Stream.runForEach(stream, (chunk) =>
115− Effect.sync(() => {
116− text += Buffer.from(chunk).toString("utf8");
117− }),
118− ).pipe(
119− Effect.mapError((cause) =>
120− normalizeCodexError(operation, cause, "Failed to collect process output"),
121− ),
122− );
123− return text;
124− });
125−
126− const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
47+ stream.pipe(
48+ Stream.decodeText(),
49+ Stream.runFold(
50+ () => "",
51+ (acc, chunk) => acc + chunk,
52+ ),
53+ Effect.mapError((cause) =>
54+ normalizeCliError("codex", operation, cause, "Failed to collect process output"),
55+ ),
56+ );
12757
12858 const writeTempFile = (
12959 operation: string,
13060 prefix: string,
13161 content: string,
132− ): Effect.Effect<string, TextGenerationError> => {
133− const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`);
134− return fileSystem.writeFileString(filePath, content).pipe(
135− Effect.mapError(
136− (cause) =>
137− new TextGenerationError({
138− operation,
139− detail: `Failed to write temp file at ${filePath}.`,
140− cause,
141− }),
142− ),
143− Effect.as(filePath),
144− );
62+ ): Effect.Effect<string, TextGenerationError, Scope.Scope> => {
63+ return fileSystem
64+ .makeTempFileScoped({
65+ prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`,
66+ })
67+ .pipe(
68+ Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)),
69+ Effect.mapError(
70+ (cause) =>
71+ new TextGenerationError({
72+ operation,
73+ detail: `Failed to write temp file`,
74+ cause,
75+ }),
76+ ),
77+ );
14578 };
14679
14780 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
@@ -187,7 +120,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 39 unmodified lines
187120 outputSchemaJson,
188121 imagePaths = [],
189122 cleanupPaths = [],
190− model,
123+ modelSelection,
191124 }: {
192125 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
193126 cwd: string;
@@ -195,17 +128,23 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 1 unmodified line
195128 outputSchemaJson: S;
196129 imagePaths?: ReadonlyArray<string>;
197130 cleanupPaths?: ReadonlyArray<string>;
198− model?: string;
131+ modelSelection: CodexModelSelection;
199132 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
200133 Effect.gen(function* () {
201134 const schemaPath = yield* writeTempFile(
202135 operation,
203136 "codex-schema",
204− JSON.stringify(toCodexOutputJsonSchema(outputSchemaJson)),
137+ JSON.stringify(toJsonSchemaObject(outputSchemaJson)),
205138 );
206139 const outputPath = yield* writeTempFile(operation, "codex-output", "");
207140
208141 const runCodexCommand = Effect.gen(function* () {
142+ const normalizedOptions = normalizeCodexModelOptions(
143+ modelSelection.model,
144+ modelSelection.options,
145+ );
146+ const reasoningEffort =
147+ modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
209148 const command = ChildProcess.make(
210149 "codex",
211150 [
@@ -214,9 +153,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 2 unmodified lines
214153 "-s",
215154 "read-only",
216155 "--model",
217− model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL,
156+ modelSelection.model,
218157 "--config",
219− `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
158+ `model_reasoning_effort="${reasoningEffort}"`,
159+ ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []),
220160 "--output-schema",
221161 schemaPath,
222162 "--output-last-message",
@@ -228,7 +168,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 5 unmodified lines
228168 cwd,
229169 shell: process.platform === "win32",
230170 stdin: {
231− stream: Stream.make(new TextEncoder().encode(prompt)),
171+ stream: Stream.encodeText(Stream.make(prompt)),
232172 },
233173 },
234174 );
@@ -237,7 +177,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 2 unmodified lines
237177 .spawn(command)
238178 .pipe(
239179 Effect.mapError((cause) =>
240− normalizeCodexError(operation, cause, "Failed to spawn Codex CLI process"),
180+ normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"),
241181 ),
242182 );
243183
@@ -246,9 +186,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 2 unmodified lines
246186 readStreamAsString(operation, child.stdout),
247187 readStreamAsString(operation, child.stderr),
248188 child.exitCode.pipe(
249− Effect.map((value) => Number(value)),
250189 Effect.mapError((cause) =>
251− normalizeCodexError(operation, cause, "Failed to read Codex CLI exit code"),
190+ normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"),
252191 ),
253192 ),
254193 ],
@@ -314,153 +253,104 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 59 unmodified lines
314253 }).pipe(Effect.ensuring(cleanup));
315254 });
316255
317− const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {
318− const wantsBranch = input.includeBranch === true;
319−
320− const prompt = [
321− "You write concise git commit messages.",
322− wantsBranch
323− ? "Return a JSON object with keys: subject, body, branch."
324− : "Return a JSON object with keys: subject, body.",
325− "Rules:",
326− "- subject must be imperative, <= 72 chars, and no trailing period",
327− "- body can be empty string or short bullet points",
328− ...(wantsBranch
329− ? ["- branch must be a short semantic git branch fragment for this change"]
330− : []),
331− "- capture the primary user-visible or developer-visible change",
332− "",
333− `Branch: ${input.branch ?? "(detached)"}`,
334− "",
335− "Staged files:",
336− limitSection(input.stagedSummary, 6_000),
337− "",
338− "Staged patch:",
339− limitSection(input.stagedPatch, 40_000),
340− ].join("\n");
341−
342− const outputSchemaJson = wantsBranch
343− ? Schema.Struct({
344− subject: Schema.String,
345− body: Schema.String,
346− branch: Schema.String,
347− })
348− : Schema.Struct({
349− subject: Schema.String,
350− body: Schema.String,
351− });
256+ const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
257+ "CodexTextGeneration.generateCommitMessage",
258+ )(function* (input) {
259+ const { prompt, outputSchema } = buildCommitMessagePrompt({
260+ branch: input.branch,
261+ stagedSummary: input.stagedSummary,
262+ stagedPatch: input.stagedPatch,
263+ includeBranch: input.includeBranch === true,
264+ });
352265
353− return runCodexJson({
266+ if (input.modelSelection.provider !== "codex") {
267+ return yield* new TextGenerationError({
268+ operation: "generateCommitMessage",
269+ detail: "Invalid model selection.",
270+ });
271+ }
272+
273+ const generated = yield* runCodexJson({
354274 operation: "generateCommitMessage",
355275 cwd: input.cwd,
356276 prompt,
357− outputSchemaJson,
358− ...(input.model ? { model: input.model } : {}),
359− }).pipe(
360− Effect.map(
361− (generated) =>
362− ({
363− subject: sanitizeCommitSubject(generated.subject),
364− body: generated.body.trim(),
365− ...("branch" in generated && typeof generated.branch === "string"
366− ? { branch: sanitizeFeatureBranchName(generated.branch) }
367− : {}),
368− }) satisfies CommitMessageGenerationResult,
369− ),
370− );
371− };
277+ outputSchemaJson: outputSchema,
278+ modelSelection: input.modelSelection,
279+ });
280+
281+ return {
282+ subject: sanitizeCommitSubject(generated.subject),
283+ body: generated.body.trim(),
284+ ...("branch" in generated && typeof generated.branch === "string"
285+ ? { branch: sanitizeFeatureBranchName(generated.branch) }
286+ : {}),
287+ };
288+ });
289+
290+ const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
291+ "CodexTextGeneration.generatePrContent",
292+ )(function* (input) {
293+ const { prompt, outputSchema } = buildPrContentPrompt({
294+ baseBranch: input.baseBranch,
295+ headBranch: input.headBranch,
296+ commitSummary: input.commitSummary,
297+ diffSummary: input.diffSummary,
298+ diffPatch: input.diffPatch,
299+ });
372300
373− const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => {
374− const prompt = [
375− "You write GitHub pull request content.",
376− "Return a JSON object with keys: title, body.",
377− "Rules:",
378− "- title should be concise and specific",
379− "- body must be markdown and include headings '## Summary' and '## Testing'",
380− "- under Summary, provide short bullet points",
381− "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
382− "",
383− `Base branch: ${input.baseBranch}`,
384− `Head branch: ${input.headBranch}`,
385− "",
386− "Commits:",
387− limitSection(input.commitSummary, 12_000),
388− "",
389− "Diff stat:",
390− limitSection(input.diffSummary, 12_000),
391− "",
392− "Diff patch:",
393− limitSection(input.diffPatch, 40_000),
394− ].join("\n");
395−
396− return runCodexJson({
301+ if (input.modelSelection.provider !== "codex") {
302+ return yield* new TextGenerationError({
303+ operation: "generatePrContent",
304+ detail: "Invalid model selection.",
305+ });
306+ }
307+
308+ const generated = yield* runCodexJson({
397309 operation: "generatePrContent",
398310 cwd: input.cwd,
399311 prompt,
400− outputSchemaJson: Schema.Struct({
401− title: Schema.String,
402− body: Schema.String,
403− }),
404− ...(input.model ? { model: input.model } : {}),
405− }).pipe(
406− Effect.map(
407− (generated) =>
408− ({
409− title: sanitizePrTitle(generated.title),
410− body: generated.body.trim(),
411− }) satisfies PrContentGenerationResult,
412− ),
413− );
414− };
312+ outputSchemaJson: outputSchema,
313+ modelSelection: input.modelSelection,
314+ });
415315
416− const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
417− return Effect.gen(function* () {
418− const { imagePaths } = yield* materializeImageAttachments(
419− "generateBranchName",
420− input.attachments,
421− );
422− const attachmentLines = (input.attachments ?? []).map(
423− (attachment) =>
424− `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
425− );
316+ return {
317+ title: sanitizePrTitle(generated.title),
318+ body: generated.body.trim(),
319+ };
320+ });
426321
427− const promptSections = [
428− "You generate concise git branch names.",
429− "Return a JSON object with key: branch.",
430− "Rules:",
431− "- Branch should describe the requested work from the user message.",
432− "- Keep it short and specific (2-6 words).",
433− "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
434− "- If images are attached, use them as primary context for visual/UI issues.",
435− "",
436− "User message:",
437− limitSection(input.message, 8_000),
438− ];
439− if (attachmentLines.length > 0) {
440− promptSections.push(
441− "",
442− "Attachment metadata:",
443− limitSection(attachmentLines.join("\n"), 4_000),
444− );
445− }
446− const prompt = promptSections.join("\n");
322+ const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
323+ "CodexTextGeneration.generateBranchName",
324+ )(function* (input) {
325+ const { imagePaths } = yield* materializeImageAttachments(
326+ "generateBranchName",
327+ input.attachments,
328+ );
329+ const { prompt, outputSchema } = buildBranchNamePrompt({
330+ message: input.message,
331+ attachments: input.attachments,
332+ });
447333
448− const generated = yield* runCodexJson({
334+ if (input.modelSelection.provider !== "codex") {
335+ return yield* new TextGenerationError({
449336 operation: "generateBranchName",
450− cwd: input.cwd,
451− prompt,
452− outputSchemaJson: Schema.Struct({
453− branch: Schema.String,
454− }),
455− imagePaths,
456− ...(input.model ? { model: input.model } : {}),
337+ detail: "Invalid model selection.",
457338 });
339+ }
458340
459− return {
460− branch: sanitizeBranchFragment(generated.branch),
461− } satisfies BranchNameGenerationResult;
341+ const generated = yield* runCodexJson({
342+ operation: "generateBranchName",
343+ cwd: input.cwd,
344+ prompt,
345+ outputSchemaJson: outputSchema,
346+ imagePaths,
347+ modelSelection: input.modelSelection,
462348 });
463− };
349+
350+ return {
351+ branch: sanitizeBranchFragment(generated.branch),
352+ };
353+ });
464354
465355 return {
466356 generateCommitMessage,
apps/server/src/git/Layers/GitManager.test.ts+6−0
@@ -21,6 +21,11 @@ import { GitCore } from "../Services/GitCore.ts"; · 20 unmodified lines
2121 import { makeGitManager } from "./GitManager.ts";
2222 import { ServerConfig } from "../../config.ts";
2323
24+const DEFAULT_TEST_MODEL_SELECTION = {
25+ provider: "codex",
26+ model: "gpt-5.4-mini",
27+} as const;
28+
2429 interface FakeGhScenario {
2530 prListSequence?: string[];
2631 prListByHeadSelector?: Record<string, string>;
@@ -461,6 +466,7 @@ function runStackedAction( · 434 unmodified lines
461466 {
462467 ...input,
463468 actionId: input.actionId ?? "test-action-id",
469+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
464470 },
465471 options,
466472 );
apps/server/src/git/Layers/GitManager.ts+12−12
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; · 1 unmodified line
22 import { realpathSync } from "node:fs";
33
44 import { Effect, FileSystem, Layer, Path } from "effect";
5−import type { GitActionProgressEvent, GitActionProgressPhase } from "@t3tools/contracts";
5+import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts";
66 import {
77 resolveAutoFeatureBranchName,
88 sanitizeBranchFragment,
@@ -684,7 +684,7 @@ export const makeGitManager = Effect.gen(function* () { · 675 unmodified lines
684684 /** When true, also produce a semantic feature branch name. */
685685 includeBranch?: boolean;
686686 filePaths?: readonly string[];
687− model?: string;
687+ modelSelection: ModelSelection;
688688 }) =>
689689 Effect.gen(function* () {
690690 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
@@ -711,7 +711,7 @@ export const makeGitManager = Effect.gen(function* () { · 20 unmodified lines
711711 stagedSummary: limitContext(context.stagedSummary, 8_000),
712712 stagedPatch: limitContext(context.stagedPatch, 50_000),
713713 ...(input.includeBranch ? { includeBranch: true } : {}),
714− ...(input.model ? { model: input.model } : {}),
714+ modelSelection: input.modelSelection,
715715 })
716716 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
717717
@@ -724,13 +724,13 @@ export const makeGitManager = Effect.gen(function* () { · 6 unmodified lines
724724 });
725725
726726 const runCommitStep = (
727+ modelSelection: ModelSelection,
727728 cwd: string,
728729 action: "commit" | "commit_push" | "commit_push_pr",
729730 branch: string | null,
730731 commitMessage?: string,
731732 preResolvedSuggestion?: CommitAndBranchSuggestion,
732733 filePaths?: readonly string[],
733− model?: string,
734734 progressReporter?: GitActionProgressReporter,
735735 actionId?: string,
736736 ) =>
@@ -760,7 +760,7 @@ export const makeGitManager = Effect.gen(function* () { · 23 unmodified lines
760760 branch,
761761 ...(commitMessage ? { commitMessage } : {}),
762762 ...(filePaths ? { filePaths } : {}),
763− ...(model ? { model } : {}),
763+ modelSelection,
764764 });
765765 }
766766 if (!suggestion) {
@@ -837,7 +837,7 @@ export const makeGitManager = Effect.gen(function* () { · 70 unmodified lines
837837 };
838838 });
839839
840− const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) =>
840+ const runPrStep = (modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null) =>
841841 Effect.gen(function* () {
842842 const details = yield* gitCore.statusDetails(cwd);
843843 const branch = details.branch ?? fallbackBranch;
@@ -881,7 +881,7 @@ export const makeGitManager = Effect.gen(function* () { · 37 unmodified lines
881881 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
882882 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
883883 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
884− ...(model ? { model } : {}),
884+ modelSelection,
885885 });
886886
887887 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
@@ -1102,11 +1102,11 @@ export const makeGitManager = Effect.gen(function* () { · 214 unmodified lines
11021102 );
11031103
11041104 const runFeatureBranchStep = (
1105+ modelSelection: ModelSelection,
11051106 cwd: string,
11061107 branch: string | null,
11071108 commitMessage?: string,
11081109 filePaths?: readonly string[],
1109− model?: string,
11101110 ) =>
11111111 Effect.gen(function* () {
11121112 const suggestion = yield* resolveCommitAndBranchSuggestion({
@@ -1115,7 +1115,7 @@ export const makeGitManager = Effect.gen(function* () { · 2 unmodified lines
11151115 ...(commitMessage ? { commitMessage } : {}),
11161116 ...(filePaths ? { filePaths } : {}),
11171117 includeBranch: true,
1118− ...(model ? { model } : {}),
1118+ modelSelection,
11191119 });
11201120 if (!suggestion) {
11211121 return yield* gitManagerError(
@@ -1181,11 +1181,11 @@ export const makeGitManager = Effect.gen(function* () { · 59 unmodified lines
11811181 label: "Preparing feature branch...",
11821182 });
11831183 const result = yield* runFeatureBranchStep(
1184+ input.modelSelection,
11841185 input.cwd,
11851186 initialStatus.branch,
11861187 input.commitMessage,
11871188 input.filePaths,
1188− input.textGenerationModel,
11891189 );
11901190 branchStep = result.branchStep;
11911191 commitMessageForStep = result.resolvedCommitMessage;
@@ -1198,13 +1198,13 @@ export const makeGitManager = Effect.gen(function* () { · 6 unmodified lines
11981198
11991199 currentPhase = "commit";
12001200 const commit = yield* runCommitStep(
1201+ input.modelSelection,
12011202 input.cwd,
12021203 input.action,
12031204 currentBranch,
12041205 commitMessageForStep,
12051206 preResolvedCommitSuggestion,
12061207 input.filePaths,
1207− input.textGenerationModel,
12081208 options?.progressReporter,
12091209 progress.actionId,
12101210 );
@@ -1237,7 +1237,7 @@ export const makeGitManager = Effect.gen(function* () { · 26 unmodified lines
12371237 Effect.flatMap(() =>
12381238 Effect.gen(function* () {
12391239 currentPhase = "pr";
1240− return yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel);
1240+ return yield* runPrStep(input.modelSelection, input.cwd, currentBranch);
12411241 }),
12421242 ),
12431243 )
apps/server/src/git/Layers/RoutingTextGeneration.ts+72−0
@@ -0,0 +1,72 @@
1+/**
2+ * RoutingTextGeneration – Dispatches text generation requests to either the
3+ * Codex CLI or Claude CLI implementation based on the provider in each
4+ * request input.
5+ *
6+ * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to
7+ * the Claude layer; for any other value (including the default `undefined`) it
8+ * falls through to the Codex layer.
9+ *
10+ * @module RoutingTextGeneration
11+ */
12+import { Effect, Layer, ServiceMap } from "effect";
13+
14+import {
15+ TextGeneration,
16+ type TextGenerationProvider,
17+ type TextGenerationShape,
18+} from "../Services/TextGeneration.ts";
19+import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
20+import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21+
22+// ---------------------------------------------------------------------------
23+// Internal service tags so both concrete layers can coexist.
24+// ---------------------------------------------------------------------------
25+
26+class CodexTextGen extends ServiceMap.Service<CodexTextGen, TextGenerationShape>()(
27+ "t3/git/Layers/RoutingTextGeneration/CodexTextGen",
28+) {}
29+
30+class ClaudeTextGen extends ServiceMap.Service<ClaudeTextGen, TextGenerationShape>()(
31+ "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
32+) {}
33+
34+// ---------------------------------------------------------------------------
35+// Routing implementation
36+// ---------------------------------------------------------------------------
37+
38+const makeRoutingTextGeneration = Effect.gen(function* () {
39+ const codex = yield* CodexTextGen;
40+ const claude = yield* ClaudeTextGen;
41+
42+ const route = (provider?: TextGenerationProvider): TextGenerationShape =>
43+ provider === "claudeAgent" ? claude : codex;
44+
45+ return {
46+ generateCommitMessage: (input) =>
47+ route(input.modelSelection.provider).generateCommitMessage(input),
48+ generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input),
49+ generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input),
50+ } satisfies TextGenerationShape;
51+});
52+
53+const InternalCodexLayer = Layer.effect(
54+ CodexTextGen,
55+ Effect.gen(function* () {
56+ const svc = yield* TextGeneration;
57+ return svc;
58+ }),
59+).pipe(Layer.provide(CodexTextGenerationLive));
60+
61+const InternalClaudeLayer = Layer.effect(
62+ ClaudeTextGen,
63+ Effect.gen(function* () {
64+ const svc = yield* TextGeneration;
65+ return svc;
66+ }),
67+).pipe(Layer.provide(ClaudeTextGenerationLive));
68+
69+export const RoutingTextGenerationLive = Layer.effect(
70+ TextGeneration,
71+ makeRoutingTextGeneration,
72+).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer));
apps/server/src/git/Prompts.test.ts+150−0
@@ -0,0 +1,150 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ buildBranchNamePrompt,
5+ buildCommitMessagePrompt,
6+ buildPrContentPrompt,
7+} from "./Prompts.ts";
8+import { normalizeCliError } from "./Utils.ts";
9+import { TextGenerationError } from "./Errors.ts";
10+
11+describe("buildCommitMessagePrompt", () => {
12+ it("includes staged patch and summary in the prompt", () => {
13+ const result = buildCommitMessagePrompt({
14+ branch: "main",
15+ stagedSummary: "M README.md",
16+ stagedPatch: "diff --git a/README.md b/README.md\n+hello",
17+ includeBranch: false,
18+ });
19+
20+ expect(result.prompt).toContain("Staged files:");
21+ expect(result.prompt).toContain("M README.md");
22+ expect(result.prompt).toContain("Staged patch:");
23+ expect(result.prompt).toContain("diff --git a/README.md b/README.md");
24+ expect(result.prompt).toContain("Branch: main");
25+ // Should NOT include the branch generation instruction
26+ expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment");
27+ });
28+
29+ it("includes branch generation instruction when includeBranch is true", () => {
30+ const result = buildCommitMessagePrompt({
31+ branch: "feature/foo",
32+ stagedSummary: "M README.md",
33+ stagedPatch: "diff",
34+ includeBranch: true,
35+ });
36+
37+ expect(result.prompt).toContain("branch must be a short semantic git branch fragment");
38+ expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch.");
39+ });
40+
41+ it("shows (detached) when branch is null", () => {
42+ const result = buildCommitMessagePrompt({
43+ branch: null,
44+ stagedSummary: "M a.ts",
45+ stagedPatch: "diff",
46+ includeBranch: false,
47+ });
48+
49+ expect(result.prompt).toContain("Branch: (detached)");
50+ });
51+});
52+
53+describe("buildPrContentPrompt", () => {
54+ it("includes branch names, commits, and diff in the prompt", () => {
55+ const result = buildPrContentPrompt({
56+ baseBranch: "main",
57+ headBranch: "feature/auth",
58+ commitSummary: "feat: add login page",
59+ diffSummary: "3 files changed",
60+ diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()",
61+ });
62+
63+ expect(result.prompt).toContain("Base branch: main");
64+ expect(result.prompt).toContain("Head branch: feature/auth");
65+ expect(result.prompt).toContain("Commits:");
66+ expect(result.prompt).toContain("feat: add login page");
67+ expect(result.prompt).toContain("Diff stat:");
68+ expect(result.prompt).toContain("3 files changed");
69+ expect(result.prompt).toContain("Diff patch:");
70+ expect(result.prompt).toContain("export function login()");
71+ });
72+});
73+
74+describe("buildBranchNamePrompt", () => {
75+ it("includes the user message in the prompt", () => {
76+ const result = buildBranchNamePrompt({
77+ message: "Fix the login timeout bug",
78+ });
79+
80+ expect(result.prompt).toContain("User message:");
81+ expect(result.prompt).toContain("Fix the login timeout bug");
82+ expect(result.prompt).not.toContain("Attachment metadata:");
83+ });
84+
85+ it("includes attachment metadata when attachments are provided", () => {
86+ const result = buildBranchNamePrompt({
87+ message: "Fix the layout from screenshot",
88+ attachments: [
89+ {
90+ type: "image" as const,
91+ id: "att-123",
92+ name: "screenshot.png",
93+ mimeType: "image/png",
94+ sizeBytes: 12345,
95+ },
96+ ],
97+ });
98+
99+ expect(result.prompt).toContain("Attachment metadata:");
100+ expect(result.prompt).toContain("screenshot.png");
101+ expect(result.prompt).toContain("image/png");
102+ expect(result.prompt).toContain("12345 bytes");
103+ });
104+});
105+
106+describe("normalizeCliError", () => {
107+ it("detects 'Command not found' and includes CLI name in the message", () => {
108+ const error = normalizeCliError(
109+ "claude",
110+ "generateCommitMessage",
111+ new Error("Command not found: claude"),
112+ "Something went wrong",
113+ );
114+
115+ expect(error).toBeInstanceOf(TextGenerationError);
116+ expect(error.detail).toContain("Claude CLI");
117+ expect(error.detail).toContain("not available on PATH");
118+ });
119+
120+ it("uses the CLI name from the first argument for codex", () => {
121+ const error = normalizeCliError(
122+ "codex",
123+ "generateBranchName",
124+ new Error("Command not found: codex"),
125+ "Something went wrong",
126+ );
127+
128+ expect(error).toBeInstanceOf(TextGenerationError);
129+ expect(error.detail).toContain("Codex CLI");
130+ expect(error.detail).toContain("not available on PATH");
131+ });
132+
133+ it("returns the error as-is if it is already a TextGenerationError", () => {
134+ const existing = new TextGenerationError({
135+ operation: "generatePrContent",
136+ detail: "Already wrapped",
137+ });
138+
139+ const result = normalizeCliError("claude", "generatePrContent", existing, "fallback");
140+
141+ expect(result).toBe(existing);
142+ });
143+
144+ it("wraps unknown non-Error values with the fallback message", () => {
145+ const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback");
146+
147+ expect(result).toBeInstanceOf(TextGenerationError);
148+ expect(result.detail).toBe("fallback");
149+ });
150+});
apps/server/src/git/Prompts.ts+153−0
@@ -0,0 +1,153 @@
1+/**
2+ * Shared prompt builders for text generation providers.
3+ *
4+ * Extracts the prompt construction logic that is identical across
5+ * Codex, Claude, and any future CLI-based text generation backends.
6+ *
7+ * @module textGenerationPrompts
8+ */
9+import { Schema } from "effect";
10+import type { ChatAttachment } from "@t3tools/contracts";
11+
12+import { limitSection } from "./Utils.ts";
13+
14+// ---------------------------------------------------------------------------
15+// Commit message
16+// ---------------------------------------------------------------------------
17+
18+export interface CommitMessagePromptInput {
19+ branch: string | null;
20+ stagedSummary: string;
21+ stagedPatch: string;
22+ includeBranch: boolean;
23+}
24+
25+export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {
26+ const wantsBranch = input.includeBranch;
27+
28+ const prompt = [
29+ "You write concise git commit messages.",
30+ wantsBranch
31+ ? "Return a JSON object with keys: subject, body, branch."
32+ : "Return a JSON object with keys: subject, body.",
33+ "Rules:",
34+ "- subject must be imperative, <= 72 chars, and no trailing period",
35+ "- body can be empty string or short bullet points",
36+ ...(wantsBranch
37+ ? ["- branch must be a short semantic git branch fragment for this change"]
38+ : []),
39+ "- capture the primary user-visible or developer-visible change",
40+ "",
41+ `Branch: ${input.branch ?? "(detached)"}`,
42+ "",
43+ "Staged files:",
44+ limitSection(input.stagedSummary, 6_000),
45+ "",
46+ "Staged patch:",
47+ limitSection(input.stagedPatch, 40_000),
48+ ].join("\n");
49+
50+ if (wantsBranch) {
51+ return {
52+ prompt,
53+ outputSchema: Schema.Struct({
54+ subject: Schema.String,
55+ body: Schema.String,
56+ branch: Schema.String,
57+ }),
58+ };
59+ }
60+
61+ return {
62+ prompt,
63+ outputSchema: Schema.Struct({
64+ subject: Schema.String,
65+ body: Schema.String,
66+ }),
67+ };
68+}
69+
70+// ---------------------------------------------------------------------------
71+// PR content
72+// ---------------------------------------------------------------------------
73+
74+export interface PrContentPromptInput {
75+ baseBranch: string;
76+ headBranch: string;
77+ commitSummary: string;
78+ diffSummary: string;
79+ diffPatch: string;
80+}
81+
82+export function buildPrContentPrompt(input: PrContentPromptInput) {
83+ const prompt = [
84+ "You write GitHub pull request content.",
85+ "Return a JSON object with keys: title, body.",
86+ "Rules:",
87+ "- title should be concise and specific",
88+ "- body must be markdown and include headings '## Summary' and '## Testing'",
89+ "- under Summary, provide short bullet points",
90+ "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
91+ "",
92+ `Base branch: ${input.baseBranch}`,
93+ `Head branch: ${input.headBranch}`,
94+ "",
95+ "Commits:",
96+ limitSection(input.commitSummary, 12_000),
97+ "",
98+ "Diff stat:",
99+ limitSection(input.diffSummary, 12_000),
100+ "",
101+ "Diff patch:",
102+ limitSection(input.diffPatch, 40_000),
103+ ].join("\n");
104+
105+ const outputSchema = Schema.Struct({
106+ title: Schema.String,
107+ body: Schema.String,
108+ });
109+
110+ return { prompt, outputSchema };
111+}
112+
113+// ---------------------------------------------------------------------------
114+// Branch name
115+// ---------------------------------------------------------------------------
116+
117+export interface BranchNamePromptInput {
118+ message: string;
119+ attachments?: ReadonlyArray<ChatAttachment> | undefined;
120+}
121+
122+export function buildBranchNamePrompt(input: BranchNamePromptInput) {
123+ const attachmentLines = (input.attachments ?? []).map(
124+ (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
125+ );
126+
127+ const promptSections = [
128+ "You generate concise git branch names.",
129+ "Return a JSON object with key: branch.",
130+ "Rules:",
131+ "- Branch should describe the requested work from the user message.",
132+ "- Keep it short and specific (2-6 words).",
133+ "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
134+ "- If images are attached, use them as primary context for visual/UI issues.",
135+ "",
136+ "User message:",
137+ limitSection(input.message, 8_000),
138+ ];
139+ if (attachmentLines.length > 0) {
140+ promptSections.push(
141+ "",
142+ "Attachment metadata:",
143+ limitSection(attachmentLines.join("\n"), 4_000),
144+ );
145+ }
146+
147+ const prompt = promptSections.join("\n");
148+ const outputSchema = Schema.Struct({
149+ branch: Schema.String,
150+ });
151+
152+ return { prompt, outputSchema };
153+}
apps/server/src/git/Services/TextGeneration.ts+10−7
@@ -8,10 +8,13 @@ · 7 unmodified lines
88 */
99 import { ServiceMap } from "effect";
1010 import type { Effect } from "effect";
11−import type { ChatAttachment } from "@t3tools/contracts";
11+import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
1212
1313 import type { TextGenerationError } from "../Errors.ts";
1414
15+/** Providers that support git text generation (commit messages, PR content, branch names). */
16+export type TextGenerationProvider = "codex" | "claudeAgent";
17+
1518 export interface CommitMessageGenerationInput {
1619 cwd: string;
1720 branch: string | null;
@@ -19,8 +22,8 @@ export interface CommitMessageGenerationInput { · 1 unmodified line
1922 stagedPatch: string;
2023 /** When true, the model also returns a semantic branch name for the change. */
2124 includeBranch?: boolean;
22− /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
23− model?: string;
25+ /** What model and provider to use for generation. */
26+ modelSelection: ModelSelection;
2427 }
2528
2629 export interface CommitMessageGenerationResult {
@@ -37,8 +40,8 @@ export interface PrContentGenerationInput { · 10 unmodified lines
3740 commitSummary: string;
3841 diffSummary: string;
3942 diffPatch: string;
40− /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
41− model?: string;
43+ /** What model and provider to use for generation. */
44+ modelSelection: ModelSelection;
4245 }
4346
4447 export interface PrContentGenerationResult {
@@ -50,8 +53,8 @@ export interface BranchNameGenerationInput { · 5 unmodified lines
5053 cwd: string;
5154 message: string;
5255 attachments?: ReadonlyArray<ChatAttachment> | undefined;
53− /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
54− model?: string;
56+ /** What model and provider to use for generation. */
57+ modelSelection: ModelSelection;
5558 }
5659
5760 export interface BranchNameGenerationResult {
apps/server/src/git/Utils.ts+102−0
@@ -0,0 +1,102 @@
1+/**
2+ * Shared utilities for text generation layers (Codex, Claude, etc.).
3+ *
4+ * @module textGenerationUtils
5+ */
6+import { Schema } from "effect";
7+
8+import { TextGenerationError } from "./Errors.ts";
9+
10+import { existsSync } from "node:fs";
11+import { join } from "node:path";
12+
13+export function isGitRepository(cwd: string): boolean {
14+ return existsSync(join(cwd, ".git"));
15+}
16+
17+/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
18+export function toJsonSchemaObject(schema: Schema.Top): unknown {
19+ const document = Schema.toJsonSchemaDocument(schema);
20+ if (document.definitions && Object.keys(document.definitions).length > 0) {
21+ return { ...document.schema, $defs: document.definitions };
22+ }
23+ return document.schema;
24+}
25+
26+/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */
27+export function limitSection(value: string, maxChars: number): string {
28+ if (value.length <= maxChars) return value;
29+ const truncated = value.slice(0, maxChars);
30+ return `${truncated}\n\n[truncated]`;
31+}
32+
33+/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
34+export function sanitizeCommitSubject(raw: string): string {
35+ const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
36+ const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
37+ if (withoutTrailingPeriod.length === 0) {
38+ return "Update project files";
39+ }
40+
41+ if (withoutTrailingPeriod.length <= 72) {
42+ return withoutTrailingPeriod;
43+ }
44+ return withoutTrailingPeriod.slice(0, 72).trimEnd();
45+}
46+
47+/** Normalise a raw PR title to a single line with a sensible fallback. */
48+export function sanitizePrTitle(raw: string): string {
49+ const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
50+ if (singleLine.length > 0) {
51+ return singleLine;
52+ }
53+ return "Update project changes";
54+}
55+
56+/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */
57+function cliLabel(cliName: string): string {
58+ const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1);
59+ return `${capitalized} CLI (\`${cliName}\`)`;
60+}
61+
62+/**
63+ * Normalize an unknown error from a CLI text generation process into a
64+ * typed `TextGenerationError`. Parameterized by CLI name so both Codex
65+ * and Claude (and future providers) can share the same logic.
66+ */
67+export function normalizeCliError(
68+ cliName: string,
69+ operation: string,
70+ error: unknown,
71+ fallback: string,
72+): TextGenerationError {
73+ if (Schema.is(TextGenerationError)(error)) {
74+ return error;
75+ }
76+
77+ if (error instanceof Error) {
78+ const lower = error.message.toLowerCase();
79+ if (
80+ error.message.includes(`Command not found: ${cliName}`) ||
81+ lower.includes(`spawn ${cliName}`) ||
82+ lower.includes("enoent")
83+ ) {
84+ return new TextGenerationError({
85+ operation,
86+ detail: `${cliLabel(cliName)} is required but not available on PATH.`,
87+ cause: error,
88+ });
89+ }
90+ return new TextGenerationError({
91+ operation,
92+ detail: `${fallback}: ${error.message}`,
93+ cause: error,
94+ });
95+ }
96+
97+ return new TextGenerationError({
98+ operation,
99+ detail: fallback,
100+ cause: error,
101+ });
102+}
apps/server/src/git/isRepo.ts+0−6
@@ -1,6 +0,0 @@
1−import { existsSync } from "node:fs";
2−import { join } from "node:path";
3−
4−export function isGitRepository(cwd: string): boolean {
5− return existsSync(join(cwd, ".git"));
6−}
apps/server/src/orchestration/Layers/CheckpointReactor.ts+1−1
@@ -24,7 +24,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; · 23 unmodified lines
2424 import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
2525 import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2626 import { OrchestrationDispatchError } from "../Errors.ts";
27−import { isGitRepository } from "../../git/isRepo.ts";
27+import { isGitRepository } from "../../git/Utils.ts";
2828
2929 type ReactorInput =
3030 | {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+5−2
@@ -1,7 +1,7 @@
11 import {
22 type ChatAttachment,
33 CommandId,
4− DEFAULT_GIT_TEXT_GENERATION_MODEL,
4+ DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
55 EventId,
66 type ModelSelection,
77 type OrchestrationEvent,
@@ -437,7 +437,10 @@ const make = Effect.gen(function* () { · 429 unmodified lines
437437 cwd,
438438 message: input.messageText,
439439 ...(attachments.length > 0 ? { attachments } : {}),
440− model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
440+ modelSelection: {
441+ provider: "codex",
442+ model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
443+ },
441444 })
442445 .pipe(
443446 Effect.catch((error) =>
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1−1
@@ -20,7 +20,7 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; · 19 unmodified lines
2020 import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
2121 import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
2222 import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
23−import { isGitRepository } from "../../git/isRepo.ts";
23+import { isGitRepository } from "../../git/Utils.ts";
2424 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2525 import {
2626 ProviderRuntimeIngestionService,
apps/server/src/serverLayers.ts+2−2
@@ -30,7 +30,7 @@ import { KeybindingsLive } from "./keybindings"; · 29 unmodified lines
3030 import { GitManagerLive } from "./git/Layers/GitManager";
3131 import { GitCoreLive } from "./git/Layers/GitCore";
3232 import { GitHubCliLive } from "./git/Layers/GitHubCli";
33−import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
33+import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
3434 import { PtyAdapter } from "./terminal/Services/PTY";
3535 import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3636
@@ -85,7 +85,7 @@ export function makeServerProviderLayer(): Layer.Layer< · 48 unmodified lines
8585 }
8686
8787 export function makeServerRuntimeServicesLayer() {
88− const textGenerationLayer = CodexTextGenerationLive;
88+ const textGenerationLayer = RoutingTextGenerationLive;
8989 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
9090
9191 const orchestrationLayer = OrchestrationEngineLive.pipe(
apps/server/src/wsServer.test.ts+12−0
@@ -1838,6 +1838,10 @@ describe("WebSocket Server", () => { · 1837 unmodified lines
18381838 actionId: "client-action-1",
18391839 cwd: "/test",
18401840 action: "commit_push",
1841+ modelSelection: {
1842+ provider: "codex",
1843+ model: "gpt-5.4-mini",
1844+ },
18411845 });
18421846 expect(response.result).toBeUndefined();
18431847 expect(response.error?.message).toContain("detached HEAD");
@@ -1846,6 +1850,10 @@ describe("WebSocket Server", () => { · 2 unmodified lines
18461850 actionId: "client-action-1",
18471851 cwd: "/test",
18481852 action: "commit_push",
1853+ modelSelection: {
1854+ provider: "codex",
1855+ model: "gpt-5.4-mini",
1856+ },
18491857 },
18501858 expect.objectContaining({
18511859 actionId: "client-action-1",
@@ -1901,6 +1909,10 @@ describe("WebSocket Server", () => { · 49 unmodified lines
19011909 actionId: "client-action-2",
19021910 cwd: "/test",
19031911 action: "commit",
1912+ modelSelection: {
1913+ provider: "codex",
1914+ model: "gpt-5.4-mini",
1915+ },
19041916 });
19051917 const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress);
19061918
apps/web/src/appSettings.test.ts+60−2
@@ -6,17 +6,20 @@ import { · 5 unmodified lines
66 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
77 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
88 DEFAULT_TIMESTAMP_FORMAT,
9+ getProviderStartOptions,
10+} from "./appSettings";
11+import {
912 getAppModelOptions,
1013 getCustomModelOptionsByProvider,
1114 getCustomModelsByProvider,
1215 getCustomModelsForProvider,
1316 getDefaultCustomModelsForProvider,
14− getProviderStartOptions,
1517 MODEL_PROVIDER_SETTINGS,
1618 normalizeCustomModelSlugs,
1719 patchCustomModels,
20+ resolveAppModelSelectionState,
1821 resolveAppModelSelection,
19−} from "./appSettings";
22+} from "./modelSelection";
2023
2124 describe("normalizeCustomModelSlugs", () => {
2225 it("normalizes aliases, removes built-ins, and deduplicates values", () => {
@@ -265,3 +268,58 @@ describe("AppSettingsSchema", () => { · 242 unmodified lines
265268 });
266269 });
267270 });
271+
272+describe("resolveAppModelSelectionState", () => {
273+ it("falls back to the default git-writing codex selection", () => {
274+ expect(
275+ resolveAppModelSelectionState({
276+ customCodexModels: [],
277+ customClaudeModels: [],
278+ textGenerationModelSelection: undefined,
279+ }),
280+ ).toEqual({
281+ provider: "codex",
282+ model: "gpt-5.4-mini",
283+ });
284+ });
285+
286+ it("preserves the selected provider and resolves saved custom models", () => {
287+ expect(
288+ resolveAppModelSelectionState({
289+ customCodexModels: [],
290+ customClaudeModels: ["claude/custom-haiku"],
291+ textGenerationModelSelection: {
292+ provider: "claudeAgent",
293+ model: "claude/custom-haiku",
294+ },
295+ }),
296+ ).toEqual({
297+ provider: "claudeAgent",
298+ model: "claude/custom-haiku",
299+ });
300+ });
301+
302+ it("normalizes provider options against the resolved model capabilities", () => {
303+ expect(
304+ resolveAppModelSelectionState({
305+ customCodexModels: [],
306+ customClaudeModels: [],
307+ textGenerationModelSelection: {
308+ provider: "claudeAgent",
309+ model: "claude-haiku-4-5",
310+ options: {
311+ effort: "max",
312+ thinking: false,
313+ fastMode: true,
314+ },
315+ },
316+ }),
317+ ).toEqual({
318+ provider: "claudeAgent",
319+ model: "claude-haiku-4-5",
320+ options: {
321+ thinking: false,
322+ },
323+ });
324+ });
325+});
apps/web/src/appSettings.ts+9−178
@@ -1,22 +1,15 @@
11 import { useCallback } from "react";
22 import { Option, Schema } from "effect";
33 import {
4− TrimmedNonEmptyString,
5− type ProviderKind,
4+ DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
5+ ModelSelection,
66 type ProviderStartOptions,
77 } from "@t3tools/contracts";
8−import {
9− getDefaultModel,
10− getModelOptions,
11− normalizeModelSlug,
12− resolveSelectableModel,
13−} from "@t3tools/shared/model";
148 import { useLocalStorage } from "./hooks/useLocalStorage";
159 import { EnvMode } from "./components/BranchToolbar.logic";
10+import { normalizeCustomModelSlugs } from "./modelSelection";
1611
1712 const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
18−const MAX_CUSTOM_MODEL_COUNT = 32;
19−export const MAX_CUSTOM_MODEL_LENGTH = 256;
2013
2114 export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
2215 export type TimestampFormat = typeof TimestampFormat.Type;
@@ -27,21 +20,6 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda · 4 unmodified lines
2720 export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
2821 export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
2922 export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
30−type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
31−export type ProviderCustomModelConfig = {
32− provider: ProviderKind;
33− settingsKey: CustomModelSettingsKey;
34− defaultSettingsKey: CustomModelSettingsKey;
35− title: string;
36− description: string;
37− placeholder: string;
38− example: string;
39−};
40−
41−const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
42− codex: new Set(getModelOptions("codex").map((option) => option.slug)),
43− claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),
44−};
4523
4624 const withDefaults =
4725 <
@@ -73,66 +51,16 @@ export const AppSettingsSchema = Schema.Struct({ · 25 unmodified lines
7351 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
7452 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
7553 customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
76− textGenerationModel: Schema.optional(TrimmedNonEmptyString),
54+ textGenerationModelSelection: ModelSelection.pipe(
55+ withDefaults(() => ({
56+ provider: "codex" as const,
57+ model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
58+ })),
59+ ),
7760 });
7861 export type AppSettings = typeof AppSettingsSchema.Type;
79−export interface AppModelOption {
80− slug: string;
81− name: string;
82− isCustom: boolean;
83−}
8462
8563 const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
86−const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {
87− codex: {
88− provider: "codex",
89− settingsKey: "customCodexModels",
90− defaultSettingsKey: "customCodexModels",
91− title: "Codex",
92− description: "Save additional Codex model slugs for the picker and `/model` command.",
93− placeholder: "your-codex-model-slug",
94− example: "gpt-6.7-codex-ultra-preview",
95− },
96− claudeAgent: {
97− provider: "claudeAgent",
98− settingsKey: "customClaudeModels",
99− defaultSettingsKey: "customClaudeModels",
100− title: "Claude",
101− description: "Save additional Claude model slugs for the picker and `/model` command.",
102− placeholder: "your-claude-model-slug",
103− example: "claude-sonnet-5-0",
104− },
105−};
106−export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
107−
108−export function normalizeCustomModelSlugs(
109− models: Iterable<string | null | undefined>,
110− provider: ProviderKind = "codex",
111−): string[] {
112− const normalizedModels: string[] = [];
113− const seen = new Set<string>();
114− const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
115−
116− for (const candidate of models) {
117− const normalized = normalizeModelSlug(candidate, provider);
118− if (
119− !normalized ||
120− normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
121− builtInModelSlugs.has(normalized) ||
122− seen.has(normalized)
123− ) {
124− continue;
125− }
126−
127− seen.add(normalized);
128− normalizedModels.push(normalized);
129− if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
130− break;
131− }
132− }
133−
134− return normalizedModels;
135−}
13664
13765 function normalizeAppSettings(settings: AppSettings): AppSettings {
13866 return {
@@ -142,103 +70,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { · 3 unmodified lines
14270 };
14371 }
14472
145−export function getCustomModelsForProvider(
146− settings: Pick<AppSettings, CustomModelSettingsKey>,
147− provider: ProviderKind,
148−): readonly string[] {
149− return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
150−}
151−
152−export function getDefaultCustomModelsForProvider(
153− defaults: Pick<AppSettings, CustomModelSettingsKey>,
154− provider: ProviderKind,
155−): readonly string[] {
156− return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
157−}
158−
159−export function patchCustomModels(
160− provider: ProviderKind,
161− models: string[],
162−): Partial<Pick<AppSettings, CustomModelSettingsKey>> {
163− return {
164− [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
165− };
166−}
167−
168−export function getCustomModelsByProvider(
169− settings: Pick<AppSettings, CustomModelSettingsKey>,
170−): Record<ProviderKind, readonly string[]> {
171− return {
172− codex: getCustomModelsForProvider(settings, "codex"),
173− claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
174− };
175−}
176−
177−export function getAppModelOptions(
178− provider: ProviderKind,
179− customModels: readonly string[],
180− selectedModel?: string | null,
181−): AppModelOption[] {
182− const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
183− slug,
184− name,
185− isCustom: false,
186− }));
187− const seen = new Set(options.map((option) => option.slug));
188− const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
189−
190− for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
191− if (seen.has(slug)) {
192− continue;
193− }
194−
195− seen.add(slug);
196− options.push({
197− slug,
198− name: slug,
199− isCustom: true,
200− });
201− }
202−
203− const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
204− const selectedModelMatchesExistingName =
205− typeof trimmedSelectedModel === "string" &&
206− options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
207− if (
208− normalizedSelectedModel &&
209− !seen.has(normalizedSelectedModel) &&
210− !selectedModelMatchesExistingName
211− ) {
212− options.push({
213− slug: normalizedSelectedModel,
214− name: normalizedSelectedModel,
215− isCustom: true,
216− });
217− }
218−
219− return options;
220−}
221−
222−export function resolveAppModelSelection(
223− provider: ProviderKind,
224− customModels: Record<ProviderKind, readonly string[]>,
225− selectedModel: string | null | undefined,
226−): string {
227− const customModelsForProvider = customModels[provider];
228− const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
229− return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
230−}
231−
232−export function getCustomModelOptionsByProvider(
233− settings: Pick<AppSettings, CustomModelSettingsKey>,
234−): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
235− const customModelsByProvider = getCustomModelsByProvider(settings);
236− return {
237− codex: getAppModelOptions("codex", customModelsByProvider.codex),
238− claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent),
239− };
240−}
241−
24273 export function getProviderStartOptions(
24374 settings: Pick<AppSettings, "claudeBinaryPath" | "codexBinaryPath" | "codexHomePath">,
24475 ): ProviderStartOptions | undefined {
apps/web/src/components/ChatView.tsx+4−5
@@ -120,13 +120,12 @@ import { · 119 unmodified lines
120120 import { SidebarTrigger } from "./ui/sidebar";
121121 import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
122122 import { readNativeApi } from "~/nativeApi";
123+import { getProviderStartOptions, useAppSettings } from "../appSettings";
123124 import {
124125 getCustomModelOptionsByProvider,
125126 getCustomModelsByProvider,
126− getProviderStartOptions,
127127 resolveAppModelSelection,
128− useAppSettings,
129−} from "../appSettings";
128+} from "../modelSelection";
130129 import { isTerminalFocused } from "../lib/terminalFocus";
131130 import {
132131 type ComposerImageAttachment,
@@ -642,8 +641,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 509 unmodified lines
642641 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
643642 const selectedModelForPicker = selectedModel;
644643 const modelOptionsByProvider = useMemo(
645− () => getCustomModelOptionsByProvider(settings),
646− [settings],
644+ () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel),
645+ [settings, selectedProvider, selectedModel],
647646 );
648647 const selectedModelForPickerWithCustomFallback = useMemo(() => {
649648 const currentOptions = modelOptionsByProvider[selectedProvider];
apps/web/src/components/GitActionsControl.tsx+1−1
@@ -261,7 +261,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 260 unmodified lines
261261 gitRunStackedActionMutationOptions({
262262 cwd: gitCwd,
263263 queryClient,
264− model: settings.textGenerationModel ?? null,
264+ modelSelection: settings.textGenerationModelSelection,
265265 }),
266266 );
267267 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+22−0
@@ -21,6 +21,7 @@ async function mountPicker(props: { · 20 unmodified lines
2121 provider: ProviderKind;
2222 model: ModelSlug;
2323 lockedProvider: ProviderKind | null;
24+ triggerVariant?: "ghost" | "outline";
2425 }) {
2526 const host = document.createElement("div");
2627 document.body.append(host);
@@ -31,6 +32,7 @@ async function mountPicker(props: { · 4 unmodified lines
3132 model={props.model}
3233 lockedProvider={props.lockedProvider}
3334 modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}
35+ triggerVariant={props.triggerVariant}
3436 onProviderModelChange={onProviderModelChange}
3537 />,
3638 { container: host },
@@ -156,4 +158,24 @@ describe("ProviderModelPicker", () => { · 119 unmodified lines
156158 await mounted.cleanup();
157159 }
158160 });
161+
162+ it("accepts outline trigger styling", async () => {
163+ const mounted = await mountPicker({
164+ provider: "codex",
165+ model: "gpt-5-codex",
166+ lockedProvider: null,
167+ triggerVariant: "outline",
168+ });
169+
170+ try {
171+ const button = document.querySelector("button");
172+ if (!(button instanceof HTMLButtonElement)) {
173+ throw new Error("Expected picker trigger button to be rendered.");
174+ }
175+ expect(button.className).toContain("border-input");
176+ expect(button.className).toContain("bg-popover");
177+ } finally {
178+ await mounted.cleanup();
179+ }
180+ });
159181 });
apps/web/src/components/chat/ProviderModelPicker.tsx+6−2
@@ -1,9 +1,10 @@
11 import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
22 import { resolveSelectableModel } from "@t3tools/shared/model";
33 import { memo, useState } from "react";
4+import type { VariantProps } from "class-variance-authority";
45 import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
56 import { ChevronDownIcon } from "lucide-react";
6−import { Button } from "../ui/button";
7+import { Button, buttonVariants } from "../ui/button";
78 import {
89 Menu,
910 MenuGroup,
@@ -56,6 +57,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 46 unmodified lines
5657 activeProviderIconClassName?: string;
5758 compact?: boolean;
5859 disabled?: boolean;
60+ triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
61+ triggerClassName?: string;
5962 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
6063 }) {
6164 const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -92,10 +95,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 30 unmodified lines
9295 render={
9396 <Button
9497 size="sm"
95− variant="ghost"
98+ variant={props.triggerVariant ?? "ghost"}
9699 className={cn(
97100 "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0",
98101 props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3",
102+ props.triggerClassName,
99103 )}
100104 disabled={props.disabled}
101105 />
apps/web/src/components/chat/TraitsPicker.browser.tsx+21−1
@@ -29,6 +29,7 @@ const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); · 28 unmodified lines
2929 function ClaudeTraitsPickerHarness(props: {
3030 model: string;
3131 fallbackModelSelection: ModelSelection | null;
32+ triggerVariant?: "ghost" | "outline";
3233 }) {
3334 const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;
3435 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
@@ -54,6 +55,7 @@ function ClaudeTraitsPickerHarness(props: { · 19 unmodified lines
5455 prompt={prompt}
5556 modelOptions={modelOptions?.claudeAgent}
5657 onPromptChange={handlePromptChange}
58+ triggerVariant={props.triggerVariant}
5759 />
5860 );
5961 }
@@ -68,6 +70,7 @@ async function mountClaudePicker(props?: { · 8 unmodified lines
6870 fastMode?: boolean;
6971 } | null;
7072 skipDraftModelOptions?: boolean;
73+ triggerVariant?: "ghost" | "outline";
7174 }) {
7275 const model = props?.model ?? "claude-opus-4-6";
7376 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;
@@ -110,7 +113,11 @@ async function mountClaudePicker(props?: { · 36 unmodified lines
110113 } satisfies ModelSelection)
111114 : null;
112115 const screen = await render(
113− <ClaudeTraitsPickerHarness model={model} fallbackModelSelection={fallbackModelSelection} />,
116+ <ClaudeTraitsPickerHarness
117+ model={model}
118+ fallbackModelSelection={fallbackModelSelection}
119+ {...(props?.triggerVariant ? { triggerVariant: props.triggerVariant } : {})}
120+ />,
114121 { container: host },
115122 );
116123
@@ -234,6 +241,19 @@ describe("TraitsPicker (Claude)", () => { · 117 unmodified lines
234241 },
235242 });
236243 });
244+
245+ it("accepts outline trigger styling", async () => {
246+ await using _ = await mountClaudePicker({
247+ triggerVariant: "outline",
248+ });
249+
250+ const button = document.querySelector("button");
251+ if (!(button instanceof HTMLButtonElement)) {
252+ throw new Error("Expected traits trigger button to be rendered.");
253+ }
254+ expect(button.className).toContain("border-input");
255+ expect(button.className).toContain("bg-popover");
256+ });
237257 });
238258
239259 // ── Codex TraitsPicker tests ──────────────────────────────────────────
apps/web/src/components/chat/TraitsPicker.tsx+55−29
@@ -14,8 +14,9 @@ import { · 13 unmodified lines
1414 hasEffortLevel,
1515 } from "@t3tools/shared/model";
1616 import { memo, useCallback, useState } from "react";
17+import type { VariantProps } from "class-variance-authority";
1718 import { ChevronDownIcon } from "lucide-react";
18−import { Button } from "../ui/button";
19+import { Button, buttonVariants } from "../ui/button";
1920 import {
2021 Menu,
2122 MenuGroup,
@@ -26,8 +27,18 @@ import { · 4 unmodified lines
2627 MenuTrigger,
2728 } from "../ui/menu";
2829 import { useComposerDraftStore } from "../../composerDraftStore";
30+import { cn } from "~/lib/utils";
2931
3032 type ProviderOptions = ProviderModelOptions[ProviderKind];
33+type TraitsPersistence =
34+ | {
35+ threadId: ThreadId;
36+ onModelOptionsChange?: never;
37+ }
38+ | {
39+ threadId?: undefined;
40+ onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void;
41+ };
3142
3243 const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
3344
@@ -57,9 +68,14 @@ function getSelectedTraits( · 23 unmodified lines
5768 model: string | null | undefined,
5869 prompt: string,
5970 modelOptions: ProviderOptions | null | undefined,
71+ allowPromptInjectedEffort: boolean,
6072 ) {
6173 const caps = getModelCapabilities(provider, model);
62− const effortLevels = caps.reasoningEffortLevels;
74+ const effortLevels = allowPromptInjectedEffort
75+ ? caps.reasoningEffortLevels
76+ : caps.reasoningEffortLevels.filter(
77+ (option) => !caps.promptInjectedEffortLevels.includes(option.value),
78+ );
6379 const defaultEffort = getDefaultEffort(caps);
6480
6581 // Resolve effort from options (provider-specific key)
@@ -88,7 +104,9 @@ function getSelectedTraits( · 22 unmodified lines
88104
89105 // Prompt-controlled effort (e.g. ultrathink in prompt text)
90106 const ultrathinkPromptControlled =
91− caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);
107+ allowPromptInjectedEffort &&
108+ caps.promptInjectedEffortLevels.length > 0 &&
109+ isClaudeUltrathinkPrompt(prompt);
92110
93111 return {
94112 caps,
@@ -102,22 +120,35 @@ function getSelectedTraits( · 7 unmodified lines
102120
103121 export interface TraitsMenuContentProps {
104122 provider: ProviderKind;
105− threadId: ThreadId;
106123 model: string | null | undefined;
107124 prompt: string;
108125 onPromptChange: (prompt: string) => void;
109126 modelOptions?: ProviderOptions | null | undefined;
127+ allowPromptInjectedEffort?: boolean;
128+ triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
129+ triggerClassName?: string;
110130 }
111131
112132 export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
113133 provider,
114− threadId,
115134 model,
116135 prompt,
117136 onPromptChange,
118137 modelOptions,
119−}: TraitsMenuContentProps) {
138+ allowPromptInjectedEffort = true,
139+ ...persistence
140+}: TraitsMenuContentProps & TraitsPersistence) {
120141 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
142+ const updateModelOptions = useCallback(
143+ (nextOptions: ProviderOptions | undefined) => {
144+ if ("onModelOptionsChange" in persistence) {
145+ persistence.onModelOptionsChange(nextOptions);
146+ return;
147+ }
148+ setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true });
149+ },
150+ [persistence, provider, setProviderModelOptions],
151+ );
121152 const {
122153 caps,
123154 effort,
@@ -125,7 +156,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 1 unmodified line
125156 thinkingEnabled,
126157 fastModeEnabled,
127158 ultrathinkPromptControlled,
128− } = getSelectedTraits(provider, model, prompt, modelOptions);
159+ } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
129160 const defaultEffort = getDefaultEffort(caps);
130161
131162 const handleEffortChange = useCallback(
@@ -143,19 +174,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 11 unmodified lines
143174 return;
144175 }
145176 const effortKey = provider === "codex" ? "reasoningEffort" : "effort";
146− setProviderModelOptions(
147− threadId,
148− provider,
177+ updateModelOptions(
149178 buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),
150− { persistSticky: true },
151179 );
152180 },
153181 [
154182 ultrathinkPromptControlled,
155183 modelOptions,
156184 onPromptChange,
157− threadId,
158− setProviderModelOptions,
185+ updateModelOptions,
159186 effortLevels,
160187 prompt,
161188 caps.promptInjectedEffortLevels,
@@ -198,11 +225,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 36 unmodified lines
198225 <MenuRadioGroup
199226 value={thinkingEnabled ? "on" : "off"}
200227 onValueChange={(value) => {
201− setProviderModelOptions(
202− threadId,
203− provider,
228+ updateModelOptions(
204229 buildNextOptions(provider, modelOptions, { thinking: value === "on" }),
205− { persistSticky: true },
206230 );
207231 }}
208232 >
@@ -219,11 +243,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 10 unmodified lines
219243 <MenuRadioGroup
220244 value={fastModeEnabled ? "on" : "off"}
221245 onValueChange={(value) => {
222− setProviderModelOptions(
223− threadId,
224− provider,
246+ updateModelOptions(
225247 buildNextOptions(provider, modelOptions, { fastMode: value === "on" }),
226− { persistSticky: true },
227248 );
228249 }}
229250 >
@@ -239,12 +260,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 9 unmodified lines
239260
240261 export const TraitsPicker = memo(function TraitsPicker({
241262 provider,
242− threadId,
243263 model,
244264 prompt,
245265 onPromptChange,
246266 modelOptions,
247−}: TraitsMenuContentProps) {
267+ allowPromptInjectedEffort = true,
268+ triggerVariant,
269+ triggerClassName,
270+ ...persistence
271+}: TraitsMenuContentProps & TraitsPersistence) {
248272 const [isMenuOpen, setIsMenuOpen] = useState(false);
249273 const {
250274 caps,
@@ -253,7 +277,7 @@ export const TraitsPicker = memo(function TraitsPicker({ · 2 unmodified lines
253277 thinkingEnabled,
254278 fastModeEnabled,
255279 ultrathinkPromptControlled,
256− } = getSelectedTraits(provider, model, prompt, modelOptions);
280+ } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort);
257281
258282 const effortLabel = effort
259283 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
@@ -284,12 +308,13 @@ export const TraitsPicker = memo(function TraitsPicker({ · 24 unmodified lines
284308 render={
285309 <Button
286310 size="sm"
287− variant="ghost"
288− className={
311+ variant={triggerVariant ?? "ghost"}
312+ className={cn(
289313 isCodexStyle
290314 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:max-w-48 sm:px-3 [&_svg]:mx-0"
291− : "shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
292− }
315+ : "shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3",
316+ triggerClassName,
317+ )}
293318 />
294319 }
295320 >
@@ -308,11 +333,12 @@ export const TraitsPicker = memo(function TraitsPicker({ · 12 unmodified lines
308333 <MenuPopup align="start">
309334 <TraitsMenuContent
310335 provider={provider}
311− threadId={threadId}
312336 model={model}
313337 prompt={prompt}
314338 onPromptChange={onPromptChange}
315339 modelOptions={modelOptions}
340+ allowPromptInjectedEffort={allowPromptInjectedEffort}
341+ {...persistence}
316342 />
317343 </MenuPopup>
318344 </Menu>
apps/web/src/composerDraftStore.ts+1−1
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
2121 } from "@t3tools/shared/model";
2222 import { useMemo } from "react";
2323 import { getLocalStorageItem } from "./hooks/useLocalStorage";
24−import { resolveAppModelSelection } from "./appSettings";
24+import { resolveAppModelSelection } from "./modelSelection";
2525 import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
2626 import {
2727 type TerminalContextDraft,
apps/web/src/lib/gitReactQuery.test.ts+8−1
@@ -29,7 +29,14 @@ describe("git mutation options", () => { · 28 unmodified lines
2929 const queryClient = new QueryClient();
3030
3131 it("attaches cwd-scoped mutation key for runStackedAction", () => {
32− const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient });
32+ const options = gitRunStackedActionMutationOptions({
33+ cwd: "/repo/a",
34+ queryClient,
35+ modelSelection: {
36+ provider: "codex",
37+ model: "gpt-5.4",
38+ },
39+ });
3340 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
3441 });
3542
apps/web/src/lib/gitReactQuery.ts+3−3
@@ -1,4 +1,4 @@
1−import type { GitStackedAction } from "@t3tools/contracts";
1+import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts";
22 import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
33 import { ensureNativeApi } from "../nativeApi";
44
@@ -112,7 +112,7 @@ export function gitCheckoutMutationOptions(input: { · 107 unmodified lines
112112 export function gitRunStackedActionMutationOptions(input: {
113113 cwd: string | null;
114114 queryClient: QueryClient;
115− model?: string | null;
115+ modelSelection: ModelSelection;
116116 }) {
117117 return mutationOptions({
118118 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
@@ -134,11 +134,11 @@ export function gitRunStackedActionMutationOptions(input: { · 15 unmodified lines
134134 return api.git.runStackedAction({
135135 actionId,
136136 cwd: input.cwd,
137+ modelSelection: input.modelSelection,
137138 action,
138139 ...(commitMessage ? { commitMessage } : {}),
139140 ...(featureBranch ? { featureBranch } : {}),
140141 ...(filePaths ? { filePaths } : {}),
141− ...(input.model ? { model: input.model } : {}),
142142 });
143143 },
144144 onSettled: async () => {
apps/web/src/modelSelection.ts+228−0
@@ -0,0 +1,228 @@
1+import {
2+ DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
3+ type ModelSelection,
4+ type ProviderKind,
5+} from "@t3tools/contracts";
6+import {
7+ getDefaultModel,
8+ getModelOptions,
9+ normalizeModelSlug,
10+ resolveSelectableModel,
11+} from "@t3tools/shared/model";
12+import { getComposerProviderState } from "./components/chat/composerProviderRegistry";
13+
14+const MAX_CUSTOM_MODEL_COUNT = 32;
15+export const MAX_CUSTOM_MODEL_LENGTH = 256;
16+
17+export type CustomModelSettings = {
18+ customCodexModels: readonly string[];
19+ customClaudeModels: readonly string[];
20+};
21+
22+export type ProviderCustomModelConfig = {
23+ provider: ProviderKind;
24+ settingsKey: keyof CustomModelSettings;
25+ defaultSettingsKey: keyof CustomModelSettings;
26+ title: string;
27+ description: string;
28+ placeholder: string;
29+ example: string;
30+};
31+
32+export interface AppModelOption {
33+ slug: string;
34+ name: string;
35+ isCustom: boolean;
36+}
37+
38+const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {
39+ codex: {
40+ provider: "codex",
41+ settingsKey: "customCodexModels",
42+ defaultSettingsKey: "customCodexModels",
43+ title: "Codex",
44+ description: "Save additional Codex model slugs for the picker and `/model` command.",
45+ placeholder: "your-codex-model-slug",
46+ example: "gpt-6.7-codex-ultra-preview",
47+ },
48+ claudeAgent: {
49+ provider: "claudeAgent",
50+ settingsKey: "customClaudeModels",
51+ defaultSettingsKey: "customClaudeModels",
52+ title: "Claude",
53+ description: "Save additional Claude model slugs for the picker and `/model` command.",
54+ placeholder: "your-claude-model-slug",
55+ example: "claude-sonnet-5-0",
56+ },
57+};
58+
59+const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
60+ codex: new Set(getModelOptions("codex").map((option) => option.slug)),
61+ claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),
62+};
63+
64+export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
65+
66+export function normalizeCustomModelSlugs(
67+ models: Iterable<string | null | undefined>,
68+ provider: ProviderKind = "codex",
69+): string[] {
70+ const normalizedModels: string[] = [];
71+ const seen = new Set<string>();
72+ const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
73+
74+ for (const candidate of models) {
75+ const normalized = normalizeModelSlug(candidate, provider);
76+ if (
77+ !normalized ||
78+ normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
79+ builtInModelSlugs.has(normalized) ||
80+ seen.has(normalized)
81+ ) {
82+ continue;
83+ }
84+
85+ seen.add(normalized);
86+ normalizedModels.push(normalized);
87+ if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
88+ break;
89+ }
90+ }
91+
92+ return normalizedModels;
93+}
94+
95+export function getCustomModelsForProvider(
96+ settings: CustomModelSettings,
97+ provider: ProviderKind,
98+): readonly string[] {
99+ return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
100+}
101+
102+export function getDefaultCustomModelsForProvider(
103+ defaults: CustomModelSettings,
104+ provider: ProviderKind,
105+): readonly string[] {
106+ return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
107+}
108+
109+export function patchCustomModels(
110+ provider: ProviderKind,
111+ models: string[],
112+): Partial<CustomModelSettings> {
113+ return {
114+ [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
115+ };
116+}
117+
118+export function getCustomModelsByProvider(
119+ settings: CustomModelSettings,
120+): Record<ProviderKind, readonly string[]> {
121+ return {
122+ codex: getCustomModelsForProvider(settings, "codex"),
123+ claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
124+ };
125+}
126+
127+export function getAppModelOptions(
128+ provider: ProviderKind,
129+ customModels: readonly string[],
130+ selectedModel?: string | null,
131+): AppModelOption[] {
132+ const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
133+ slug,
134+ name,
135+ isCustom: false,
136+ }));
137+ const seen = new Set(options.map((option) => option.slug));
138+ const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
139+
140+ for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
141+ if (seen.has(slug)) {
142+ continue;
143+ }
144+
145+ seen.add(slug);
146+ options.push({
147+ slug,
148+ name: slug,
149+ isCustom: true,
150+ });
151+ }
152+
153+ const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
154+ const selectedModelMatchesExistingName =
155+ typeof trimmedSelectedModel === "string" &&
156+ options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
157+ if (
158+ normalizedSelectedModel &&
159+ !seen.has(normalizedSelectedModel) &&
160+ !selectedModelMatchesExistingName
161+ ) {
162+ options.push({
163+ slug: normalizedSelectedModel,
164+ name: normalizedSelectedModel,
165+ isCustom: true,
166+ });
167+ }
168+
169+ return options;
170+}
171+
172+export function resolveAppModelSelection(
173+ provider: ProviderKind,
174+ customModels: Record<ProviderKind, readonly string[]>,
175+ selectedModel: string | null | undefined,
176+): string {
177+ const customModelsForProvider = customModels[provider];
178+ const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
179+ return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
180+}
181+
182+export function getCustomModelOptionsByProvider(
183+ settings: CustomModelSettings,
184+ selectedProvider?: ProviderKind | null,
185+ selectedModel?: string | null,
186+): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
187+ const customModelsByProvider = getCustomModelsByProvider(settings);
188+ return {
189+ codex: getAppModelOptions(
190+ "codex",
191+ customModelsByProvider.codex,
192+ selectedProvider === "codex" ? selectedModel : undefined,
193+ ),
194+ claudeAgent: getAppModelOptions(
195+ "claudeAgent",
196+ customModelsByProvider.claudeAgent,
197+ selectedProvider === "claudeAgent" ? selectedModel : undefined,
198+ ),
199+ };
200+}
201+
202+export function resolveAppModelSelectionState(
203+ settings: CustomModelSettings & {
204+ textGenerationModelSelection: ModelSelection | undefined;
205+ },
206+): ModelSelection {
207+ const selection = settings.textGenerationModelSelection ?? {
208+ provider: "codex" as const,
209+ model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
210+ };
211+ const provider = selection.provider;
212+ const customModelsByProvider = getCustomModelsByProvider(settings);
213+ const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model);
214+ const { modelOptionsForDispatch } = getComposerProviderState({
215+ provider,
216+ model,
217+ prompt: "",
218+ modelOptions: {
219+ [provider]: selection.options,
220+ },
221+ });
222+
223+ return {
224+ provider,
225+ model,
226+ ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}),
227+ };
228+}
apps/web/src/routes/_chat.settings.tsx+66−47
@@ -2,16 +2,17 @@ import { createFileRoute } from "@tanstack/react-router"; · 1 unmodified line
22 import { useQuery } from "@tanstack/react-query";
33 import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react";
44 import { type ReactNode, useCallback, useState } from "react";
5−import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
5+import { type ProviderKind } from "@t3tools/contracts";
66 import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
7+import { useAppSettings } from "../appSettings";
78 import {
8− getAppModelOptions,
9+ getCustomModelOptionsByProvider,
910 getCustomModelsForProvider,
1011 MAX_CUSTOM_MODEL_LENGTH,
1112 MODEL_PROVIDER_SETTINGS,
1213 patchCustomModels,
13− useAppSettings,
14−} from "../appSettings";
14+ resolveAppModelSelectionState,
15+} from "../modelSelection";
1516 import { APP_VERSION } from "../branding";
1617 import { Button } from "../components/ui/button";
1718 import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
@@ -25,6 +26,8 @@ import { · 7 unmodified lines
2526 } from "../components/ui/select";
2627 import { SidebarTrigger } from "../components/ui/sidebar";
2728 import { Switch } from "../components/ui/switch";
29+import { ProviderModelPicker } from "../components/chat/ProviderModelPicker";
30+import { TraitsPicker } from "../components/chat/TraitsPicker";
2831 import { SidebarInset } from "../components/ui/sidebar";
2932 import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
3033 import { resolveAndPersistPreferredEditor } from "../editorPreferences";
@@ -214,20 +217,15 @@ function SettingsRouteView() { · 183 unmodified lines
214217 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
215218 const availableEditors = serverConfigQuery.data?.availableEditors;
216219
217− const gitTextGenerationModelOptions = getAppModelOptions(
218− "codex",
219− settings.customCodexModels,
220− settings.textGenerationModel,
220+ const textGenerationModelSelection = resolveAppModelSelectionState(settings);
221+ const textGenProvider = textGenerationModelSelection.provider;
222+ const textGenModel = textGenerationModelSelection.model;
223+ const textGenModelOptions = textGenerationModelSelection.options;
224+ const gitModelOptionsByProvider = getCustomModelOptionsByProvider(
225+ settings,
226+ textGenProvider,
227+ textGenModel,
221228 );
222− const currentGitTextGenerationModel =
223− settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL;
224− const defaultGitTextGenerationModel =
225− defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL;
226− const isGitTextGenerationModelDirty =
227− currentGitTextGenerationModel !== defaultGitTextGenerationModel;
228− const selectedGitTextGenerationModelLabel =
229− gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel)
230− ?.name ?? currentGitTextGenerationModel;
231229 const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(
232230 (providerSettings) => providerSettings.provider === selectedCustomModelProvider,
233231 )!;
@@ -260,7 +258,10 @@ function SettingsRouteView() { · 26 unmodified lines
260258 ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
261259 ? ["Delete confirmation"]
262260 : []),
263− ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []),
261+ ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !==
262+ JSON.stringify(defaults.textGenerationModelSelection ?? null)
263+ ? ["Git writing model"]
264+ : []),
264265 ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
265266 ? ["Custom models"]
266267 : []),
@@ -631,43 +632,61 @@ function SettingsRouteView() { · 364 unmodified lines
631632 <SettingsSection title="Models">
632633 <SettingsRow
633634 title="Git writing model"
634− description="Used for generated commit messages, PR titles, and branch names."
635+ description="Provider and model used for auto-generated git content."
635636 resetAction={
636− isGitTextGenerationModelDirty ? (
637+ JSON.stringify(settings.textGenerationModelSelection ?? null) !==
638+ JSON.stringify(defaults.textGenerationModelSelection ?? null) ? (
637639 <SettingResetButton
638640 label="git writing model"
639− onClick={() =>
641+ onClick={() => {
640642 updateSettings({
641− textGenerationModel: defaults.textGenerationModel,
642− })
643− }
643+ textGenerationModelSelection: defaults.textGenerationModelSelection,
644+ });
645+ }}
644646 />
645647 ) : null
646648 }
647649 control={
648− <Select
649− value={currentGitTextGenerationModel}
650− onValueChange={(value) => {
651− if (!value) return;
652− updateSettings({
653− textGenerationModel: value,
654− });
655− }}
656− >
657− <SelectTrigger
658− className="w-full sm:w-52"
659− aria-label="Git text generation model"
660− >
661− <SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>
662− </SelectTrigger>
663− <SelectPopup align="end" alignItemWithTrigger={false}>
664− {gitTextGenerationModelOptions.map((option) => (
665− <SelectItem hideIndicator key={option.slug} value={option.slug}>
666− {option.name}
667− </SelectItem>
668− ))}
669− </SelectPopup>
670− </Select>
650+ <div className="flex flex-wrap items-center justify-end gap-1.5">
651+ <ProviderModelPicker
652+ provider={textGenProvider}
653+ model={textGenModel}
654+ lockedProvider={null}
655+ modelOptionsByProvider={gitModelOptionsByProvider}
656+ triggerVariant="outline"
657+ triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
658+ onProviderModelChange={(provider, model) => {
659+ updateSettings({
660+ textGenerationModelSelection: resolveAppModelSelectionState({
661+ ...settings,
662+ textGenerationModelSelection: { provider, model },
663+ }),
664+ });
665+ }}
666+ />
667+ <TraitsPicker
668+ provider={textGenProvider}
669+ model={textGenModel}
670+ prompt=""
671+ onPromptChange={() => {}}
672+ modelOptions={textGenModelOptions}
673+ allowPromptInjectedEffort={false}
674+ triggerVariant="outline"
675+ triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
676+ onModelOptionsChange={(nextOptions) => {
677+ updateSettings({
678+ textGenerationModelSelection: resolveAppModelSelectionState({
679+ ...settings,
680+ textGenerationModelSelection: {
681+ provider: textGenProvider,
682+ model: textGenModel,
683+ ...(nextOptions ? { options: nextOptions } : {}),
684+ },
685+ }),
686+ });
687+ }}
688+ />
689+ </div>
671690 }
672691 />
673692
apps/web/src/wsNativeApi.test.ts+18−2
@@ -353,11 +353,27 @@ describe("wsNativeApi", () => { · 352 unmodified lines
353353 const { createWsNativeApi } = await import("./wsNativeApi");
354354
355355 const api = createWsNativeApi();
356− await api.git.runStackedAction({ actionId: "action-1", cwd: "/repo", action: "commit" });
356+ await api.git.runStackedAction({
357+ actionId: "action-1",
358+ cwd: "/repo",
359+ action: "commit",
360+ modelSelection: {
361+ provider: "codex",
362+ model: "gpt-5.4-mini",
363+ },
364+ });
357365
358366 expect(requestMock).toHaveBeenCalledWith(
359367 WS_METHODS.gitRunStackedAction,
360− { actionId: "action-1", cwd: "/repo", action: "commit" },
368+ {
369+ actionId: "action-1",
370+ cwd: "/repo",
371+ action: "commit",
372+ modelSelection: {
373+ provider: "codex",
374+ model: "gpt-5.4-mini",
375+ },
376+ },
361377 { timeoutMs: null },
362378 );
363379 });
packages/contracts/src/git.test.ts+19−0
@@ -65,9 +65,28 @@ describe("GitRunStackedActionInput", () => { · 64 unmodified lines
6565 actionId: "action-1",
6666 cwd: "/repo",
6767 action: "commit",
68+ modelSelection: {
69+ provider: "codex",
70+ model: "gpt-5.4-mini",
71+ },
6872 });
6973
7074 expect(parsed.actionId).toBe("action-1");
7175 expect(parsed.action).toBe("commit");
7276 });
77+
78+ it("accepts git text generation as a modelSelection", () => {
79+ const parsed = decodeRunStackedActionInput({
80+ actionId: "action-1",
81+ cwd: "/repo",
82+ action: "commit_push_pr",
83+ modelSelection: {
84+ provider: "claudeAgent",
85+ model: "claude-haiku-4-5",
86+ },
87+ });
88+
89+ expect(parsed.modelSelection?.provider).toBe("claudeAgent");
90+ expect(parsed.modelSelection?.model).toBe("claude-haiku-4-5");
91+ });
7392 });
packages/contracts/src/git.ts+3−5
@@ -1,6 +1,6 @@
1−import { Option, Schema } from "effect";
1+import { Schema } from "effect";
22 import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3−import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model";
3+import { ModelSelection } from "./orchestration";
44
55 const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
66
@@ -80,9 +80,7 @@ export const GitRunStackedActionInput = Schema.Struct({ · 73 unmodified lines
8080 filePaths: Schema.optional(
8181 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
8282 ),
83− textGenerationModel: Schema.optional(TrimmedNonEmptyStringSchema).pipe(
84− Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL)),
85− ),
83+ modelSelection: ModelSelection,
8684 });
8785 export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
8886
packages/contracts/src/model.ts+6−1
@@ -199,7 +199,12 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = { · 198 unmodified lines
199199 // Backward compatibility for existing Codex-only call sites.
200200 export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex;
201201 export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
202−export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const;
202+
203+/** Per-provider text generation model defaults. */
204+export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
205+ codex: "gpt-5.4-mini",
206+ claudeAgent: "claude-haiku-4-5",
207+};
203208
204209 export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {
205210 codex: {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t