History

Add ACP support with Cursor provider (#1355)

Julius Marminge committed 7 months ago

Commit 9c64f121 parent 0f184c298 files · +26731 −847Browse files

Showing the changes to apps/server/src/git (5 of 98 files). Show all

Unified · Split

5 files
  1. apps/server/src/git/Layers/ClaudeTextGeneration.ts +2 −2
  2. apps/server/src/git/Layers/CursorTextGeneration.test.ts +298 −0
  3. apps/server/src/git/Layers/CursorTextGeneration.ts +352 −0
  4. apps/server/src/git/Layers/RoutingTextGeneration.ts +22 −1
  5. apps/server/src/git/Services/TextGeneration.ts +1 −1
apps/server/src/git/Layers/ClaudeTextGeneration.ts+2−2
@@ -11,7 +11,6 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; · 10 unmodified lines
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12 12
13import { ClaudeModelSelection } from "@t3tools/contracts";13import { ClaudeModelSelection } from "@t3tools/contracts";
14import { resolveApiModelId } from "@t3tools/shared/model";
15import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
16 15
17import { TextGenerationError } from "@t3tools/contracts";16import { TextGenerationError } from "@t3tools/contracts";
@@ -30,6 +29,7 @@ import { · 12 unmodified lines
30 toJsonSchemaObject,29 toJsonSchemaObject,
31} from "../Utils.ts";30} from "../Utils.ts";
32import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";31import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";
32import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts";
33import { ServerSettingsService } from "../../serverSettings.ts";33import { ServerSettingsService } from "../../serverSettings.ts";
34import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";34import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";
35 35
@@ -110,7 +110,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 74 unmodified lines
110 "--json-schema",110 "--json-schema",
111 jsonSchemaStr,111 jsonSchemaStr,
112 "--model",112 "--model",
113 resolveApiModelId(modelSelection),113 resolveClaudeApiModelId(modelSelection),
114 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),114 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
115 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),115 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
116 "--dangerously-skip-permissions",116 "--dangerously-skip-permissions",
apps/server/src/git/Layers/CursorTextGeneration.test.ts+298−0
@@ -0,0 +1,298 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { fileURLToPath } from "node:url";
4import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";
8import { Effect, Layer } from "effect";
9import { expect } from "vitest";
10
11import { ServerSettingsError } from "@t3tools/contracts";
12
13import { ServerConfig } from "../../config.ts";
14import { TextGeneration } from "../Services/TextGeneration.ts";
15import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
16import { ServerSettingsService } from "../../serverSettings.ts";
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
20
21function shellSingleQuote(value: string): string {
22 return `'${value.replaceAll("'", `'"'"'`)}'`;
23}
24
25const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe(
26 Layer.provideMerge(ServerSettingsService.layerTest()),
27 Layer.provideMerge(
28 ServerConfig.layerTest(process.cwd(), {
29 prefix: "t3code-cursor-text-generation-test-",
30 }),
31 ),
32 Layer.provideMerge(NodeServices.layer),
33);
34
35function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
36 const binDir = path.join(dir, "bin");
37 const agentPath = path.join(binDir, "agent");
38 mkdirSync(binDir, { recursive: true });
39 writeFileSync(
40 agentPath,
41 [
42 "#!/bin/sh",
43 ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
44 'if [ "$1" != "acp" ]; then',
45 ' printf "%s\\n" "unexpected args: $*" >&2',
46 " exit 11",
47 "fi",
48 `exec bun ${JSON.stringify(mockAgentPath)}`,
49 "",
50 ].join("\n"),
51 "utf8",
52 );
53 chmodSync(agentPath, 0o755);
54 return agentPath;
55}
56
57function withFakeAcpAgent<A, E, R>(
58 env: Record<string, string>,
59 effect: Effect.Effect<A, E, R>,
60): Effect.Effect<A, E | ServerSettingsError, R | ServerSettingsService> {
61 return Effect.gen(function* () {
62 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
63 const agentPath = makeAcpAgentWrapper(tempDir, env);
64 const serverSettings = yield* ServerSettingsService;
65 const previousSettings = yield* serverSettings.getSettings;
66
67 yield* serverSettings.updateSettings({
68 providers: {
69 cursor: {
70 binaryPath: agentPath,
71 },
72 },
73 });
74
75 return yield* effect.pipe(
76 Effect.ensuring(
77 serverSettings
78 .updateSettings({
79 providers: {
80 cursor: {
81 binaryPath: previousSettings.providers.cursor.binaryPath,
82 },
83 },
84 })
85 .pipe(
86 Effect.catch(() => Effect.void),
87 Effect.ensuring(
88 Effect.sync(() => {
89 rmSync(tempDir, { recursive: true, force: true });
90 }),
91 ),
92 Effect.asVoid,
93 ),
94 ),
95 );
96 });
97}
98
99function waitForFileContent(path: string): Effect.Effect<string> {
100 return Effect.promise(async () => {
101 const deadline = Date.now() + 5_000;
102 for (;;) {
103 try {
104 return readFileSync(path, "utf8");
105 } catch (error) {
106 if (Date.now() >= deadline) {
107 throw error instanceof Error ? error : new Error(String(error));
108 }
109 }
110 await new Promise((resolve) => setTimeout(resolve, 25));
111 }
112 });
113}
114
115it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
116 it.effect("uses ACP model config options instead of raw CLI model ids", () => {
117 const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-"));
118 const requestLogPath = path.join(requestLogDir, "requests.ndjson");
119
120 return withFakeAcpAgent(
121 {
122 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
123 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
124 subject: "Add generated commit message",
125 body: "- verify cursor acp model config path",
126 }),
127 },
128 Effect.gen(function* () {
129 const textGeneration = yield* TextGeneration;
130
131 const generated = yield* textGeneration.generateCommitMessage({
132 cwd: process.cwd(),
133 branch: "feature/cursor-text-generation",
134 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
135 stagedPatch:
136 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
137 modelSelection: {
138 provider: "cursor",
139 model: "gpt-5.4",
140 options: {
141 reasoning: "xhigh",
142 fastMode: true,
143 contextWindow: "1m",
144 },
145 },
146 });
147
148 expect(generated.subject).toBe("Add generated commit message");
149 expect(generated.body).toBe("- verify cursor acp model config path");
150
151 const requests = readFileSync(requestLogPath, "utf8")
152 .trim()
153 .split("\n")
154 .filter((line) => line.length > 0)
155 .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });
156
157 expect(
158 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
159 ).toMatchObject({
160 _meta: {
161 parameterizedModelPicker: true,
162 },
163 });
164 expect(
165 requests.some(
166 (request) =>
167 request.method === "session/set_config_option" &&
168 request.params?.configId === "model" &&
169 request.params?.value === "gpt-5.4",
170 ),
171 ).toBe(true);
172 expect(
173 requests.some(
174 (request) =>
175 request.method === "session/set_config_option" &&
176 request.params?.configId === "reasoning" &&
177 request.params?.value === "extra-high",
178 ),
179 ).toBe(true);
180 expect(
181 requests.some(
182 (request) =>
183 request.method === "session/set_config_option" &&
184 request.params?.configId === "context" &&
185 request.params?.value === "1m",
186 ),
187 ).toBe(true);
188 expect(
189 requests.some(
190 (request) =>
191 request.method === "session/set_config_option" &&
192 request.params?.configId === "fast" &&
193 request.params?.value === "true",
194 ),
195 ).toBe(true);
196 expect(
197 requests.find((request) => request.method === "session/prompt")?.params?.prompt,
198 ).toEqual(
199 expect.arrayContaining([
200 expect.objectContaining({
201 type: "text",
202 text: expect.stringContaining("Staged patch:"),
203 }),
204 ]),
205 );
206
207 rmSync(requestLogDir, { recursive: true, force: true });
208 }),
209 );
210 });
211
212 it.effect("accepts json objects with extra assistant text around them", () =>
213 withFakeAcpAgent(
214 {
215 T3_ACP_PROMPT_RESPONSE_TEXT:
216 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
217 },
218 Effect.gen(function* () {
219 const textGeneration = yield* TextGeneration;
220
221 const generated = yield* textGeneration.generateCommitMessage({
222 cwd: process.cwd(),
223 branch: "feature/cursor-noisy-json",
224 stagedSummary: "M README.md",
225 stagedPatch: "diff --git a/README.md b/README.md",
226 modelSelection: {
227 provider: "cursor",
228 model: "composer-2",
229 },
230 });
231
232 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
233 expect(generated.body).toBe("");
234 }),
235 ),
236 );
237
238 it.effect("generates thread titles through Cursor ACP text generation", () =>
239 withFakeAcpAgent(
240 {
241 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
242 title: '"Trim reconnect spinner status after resume."',
243 }),
244 },
245 Effect.gen(function* () {
246 const textGeneration = yield* TextGeneration;
247
248 const generated = yield* textGeneration.generateThreadTitle({
249 cwd: process.cwd(),
250 message: "Fix the reconnect spinner after a resumed session.",
251 modelSelection: {
252 provider: "cursor",
253 model: "composer-2",
254 },
255 });
256
257 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
258 }),
259 ),
260 );
261
262 it.effect("closes the ACP child process after text generation completes", () => {
263 const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-"));
264 const exitLogPath = path.join(exitLogDir, "exit.log");
265
266 return withFakeAcpAgent(
267 {
268 T3_ACP_EXIT_LOG_PATH: exitLogPath,
269 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
270 subject: "Close runtime after generation",
271 body: "",
272 }),
273 },
274 Effect.gen(function* () {
275 const textGeneration = yield* TextGeneration;
276
277 const generated = yield* textGeneration.generateCommitMessage({
278 cwd: process.cwd(),
279 branch: "feature/cursor-runtime-close",
280 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
281 stagedPatch:
282 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
283 modelSelection: {
284 provider: "cursor",
285 model: "composer-2",
286 },
287 });
288
289 expect(generated.subject).toBe("Close runtime after generation");
290
291 const exitLog = yield* waitForFileContent(exitLogPath);
292 expect(exitLog).toContain("exit:0");
293
294 rmSync(exitLogDir, { recursive: true, force: true });
295 }),
296 );
297 });
298});
apps/server/src/git/Layers/CursorTextGeneration.ts+352−0
@@ -0,0 +1,352 @@
1import { Effect, Layer, Option, Ref, Schema } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";
3
4import { CursorModelSelection } from "@t3tools/contracts";
5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6
7import { TextGenerationError } from "@t3tools/contracts";
8import {
9 type ThreadTitleGenerationResult,
10 type TextGenerationShape,
11 TextGeneration,
12} from "../Services/TextGeneration.ts";
13import {
14 buildBranchNamePrompt,
15 buildCommitMessagePrompt,
16 buildPrContentPrompt,
17 buildThreadTitlePrompt,
18} from "../Prompts.ts";
19import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts";
20import {
21 applyCursorAcpModelSelection,
22 makeCursorAcpRuntime,
23} from "../../provider/acp/CursorAcpSupport.ts";
24import { ServerSettingsService } from "../../serverSettings.ts";
25
26const CURSOR_TIMEOUT_MS = 180_000;
27
28function extractJsonObject(raw: string): string {
29 const trimmed = raw.trim();
30 if (trimmed.length === 0) {
31 return trimmed;
32 }
33
34 const start = trimmed.indexOf("{");
35 if (start < 0) {
36 return trimmed;
37 }
38
39 let depth = 0;
40 let inString = false;
41 let escaping = false;
42 for (let index = start; index < trimmed.length; index += 1) {
43 const char = trimmed[index];
44 if (inString) {
45 if (escaping) {
46 escaping = false;
47 } else if (char === "\\") {
48 escaping = true;
49 } else if (char === '"') {
50 inString = false;
51 }
52 continue;
53 }
54
55 if (char === '"') {
56 inString = true;
57 continue;
58 }
59
60 if (char === "{") {
61 depth += 1;
62 continue;
63 }
64
65 if (char === "}") {
66 depth -= 1;
67 if (depth === 0) {
68 return trimmed.slice(start, index + 1);
69 }
70 }
71 }
72
73 return trimmed.slice(start);
74}
75
76function mapCursorAcpError(
77 operation:
78 | "generateCommitMessage"
79 | "generatePrContent"
80 | "generateBranchName"
81 | "generateThreadTitle",
82 detail: string,
83 cause: unknown,
84): TextGenerationError {
85 return new TextGenerationError({
86 operation,
87 detail,
88 ...(cause !== undefined ? { cause } : {}),
89 });
90}
91
92function isTextGenerationError(error: unknown): error is TextGenerationError {
93 return (
94 typeof error === "object" &&
95 error !== null &&
96 "_tag" in error &&
97 error._tag === "TextGenerationError"
98 );
99}
100
101const makeCursorTextGeneration = Effect.gen(function* () {
102 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
103 const serverSettingsService = yield* Effect.service(ServerSettingsService);
104
105 const runCursorJson = <S extends Schema.Top>({
106 operation,
107 cwd,
108 prompt,
109 outputSchemaJson,
110 modelSelection,
111 }: {
112 operation:
113 | "generateCommitMessage"
114 | "generatePrContent"
115 | "generateBranchName"
116 | "generateThreadTitle";
117 cwd: string;
118 prompt: string;
119 outputSchemaJson: S;
120 modelSelection: CursorModelSelection;
121 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
122 Effect.gen(function* () {
123 const cursorSettings = yield* Effect.map(
124 serverSettingsService.getSettings,
125 (settings) => settings.providers.cursor,
126 ).pipe(Effect.catch(() => Effect.undefined));
127
128 const outputRef = yield* Ref.make("");
129 const runtime = yield* makeCursorAcpRuntime({
130 cursorSettings,
131 childProcessSpawner: commandSpawner,
132 cwd,
133 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
134 });
135
136 yield* runtime.handleSessionUpdate((notification) => {
137 const update = notification.update;
138 if (update.sessionUpdate !== "agent_message_chunk") {
139 return Effect.void;
140 }
141 const content = update.content;
142 if (content.type !== "text") {
143 return Effect.void;
144 }
145 return Ref.update(outputRef, (current) => current + content.text);
146 });
147
148 const promptResult = yield* Effect.gen(function* () {
149 yield* runtime.start();
150 yield* Effect.ignore(runtime.setMode("ask"));
151 yield* applyCursorAcpModelSelection({
152 runtime,
153 model: modelSelection.model,
154 modelOptions: modelSelection.options,
155 mapError: ({ cause, configId, step }) =>
156 mapCursorAcpError(
157 operation,
158 step === "set-config-option"
159 ? `Failed to set Cursor ACP config option "${configId}" for text generation.`
160 : "Failed to set Cursor ACP base model for text generation.",
161 cause,
162 ),
163 });
164
165 return yield* runtime.prompt({
166 prompt: [{ type: "text", text: prompt }],
167 });
168 }).pipe(
169 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
170 Effect.flatMap(
171 Option.match({
172 onNone: () =>
173 Effect.fail(
174 new TextGenerationError({
175 operation,
176 detail: "Cursor Agent request timed out.",
177 }),
178 ),
179 onSome: (value) => Effect.succeed(value),
180 }),
181 ),
182 Effect.mapError((cause) =>
183 isTextGenerationError(cause)
184 ? cause
185 : mapCursorAcpError(operation, "Cursor ACP request failed.", cause),
186 ),
187 );
188
189 const rawResult = (yield* Ref.get(outputRef)).trim();
190 if (!rawResult) {
191 return yield* new TextGenerationError({
192 operation,
193 detail:
194 promptResult.stopReason === "cancelled"
195 ? "Cursor ACP request was cancelled."
196 : "Cursor Agent returned empty output.",
197 });
198 }
199
200 return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))(
201 extractJsonObject(rawResult),
202 ).pipe(
203 Effect.catchTag("SchemaError", (cause) =>
204 Effect.fail(
205 new TextGenerationError({
206 operation,
207 detail: "Cursor Agent returned invalid structured output.",
208 cause,
209 }),
210 ),
211 ),
212 );
213 }).pipe(
214 Effect.mapError((cause) =>
215 isTextGenerationError(cause)
216 ? cause
217 : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause),
218 ),
219 Effect.scoped,
220 );
221
222 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
223 "CursorTextGeneration.generateCommitMessage",
224 )(function* (input) {
225 const { prompt, outputSchema } = buildCommitMessagePrompt({
226 branch: input.branch,
227 stagedSummary: input.stagedSummary,
228 stagedPatch: input.stagedPatch,
229 includeBranch: input.includeBranch === true,
230 });
231
232 if (input.modelSelection.provider !== "cursor") {
233 return yield* new TextGenerationError({
234 operation: "generateCommitMessage",
235 detail: "Invalid model selection.",
236 });
237 }
238
239 const generated = yield* runCursorJson({
240 operation: "generateCommitMessage",
241 cwd: input.cwd,
242 prompt,
243 outputSchemaJson: outputSchema,
244 modelSelection: input.modelSelection,
245 });
246
247 return {
248 subject: sanitizeCommitSubject(generated.subject),
249 body: generated.body.trim(),
250 ...("branch" in generated && typeof generated.branch === "string"
251 ? { branch: sanitizeFeatureBranchName(generated.branch) }
252 : {}),
253 };
254 });
255
256 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
257 "CursorTextGeneration.generatePrContent",
258 )(function* (input) {
259 const { prompt, outputSchema } = buildPrContentPrompt({
260 baseBranch: input.baseBranch,
261 headBranch: input.headBranch,
262 commitSummary: input.commitSummary,
263 diffSummary: input.diffSummary,
264 diffPatch: input.diffPatch,
265 });
266
267 if (input.modelSelection.provider !== "cursor") {
268 return yield* new TextGenerationError({
269 operation: "generatePrContent",
270 detail: "Invalid model selection.",
271 });
272 }
273
274 const generated = yield* runCursorJson({
275 operation: "generatePrContent",
276 cwd: input.cwd,
277 prompt,
278 outputSchemaJson: outputSchema,
279 modelSelection: input.modelSelection,
280 });
281
282 return {
283 title: sanitizePrTitle(generated.title),
284 body: generated.body.trim(),
285 };
286 });
287
288 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
289 "CursorTextGeneration.generateBranchName",
290 )(function* (input) {
291 const { prompt, outputSchema } = buildBranchNamePrompt({
292 message: input.message,
293 attachments: input.attachments,
294 });
295
296 if (input.modelSelection.provider !== "cursor") {
297 return yield* new TextGenerationError({
298 operation: "generateBranchName",
299 detail: "Invalid model selection.",
300 });
301 }
302
303 const generated = yield* runCursorJson({
304 operation: "generateBranchName",
305 cwd: input.cwd,
306 prompt,
307 outputSchemaJson: outputSchema,
308 modelSelection: input.modelSelection,
309 });
310
311 return {
312 branch: sanitizeBranchFragment(generated.branch),
313 };
314 });
315
316 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
317 "CursorTextGeneration.generateThreadTitle",
318 )(function* (input) {
319 const { prompt, outputSchema } = buildThreadTitlePrompt({
320 message: input.message,
321 attachments: input.attachments,
322 });
323
324 if (input.modelSelection.provider !== "cursor") {
325 return yield* new TextGenerationError({
326 operation: "generateThreadTitle",
327 detail: "Invalid model selection.",
328 });
329 }
330
331 const generated = yield* runCursorJson({
332 operation: "generateThreadTitle",
333 cwd: input.cwd,
334 prompt,
335 outputSchemaJson: outputSchema,
336 modelSelection: input.modelSelection,
337 });
338
339 return {
340 title: sanitizeThreadTitle(generated.title),
341 } satisfies ThreadTitleGenerationResult;
342 });
343
344 return {
345 generateCommitMessage,
346 generatePrContent,
347 generateBranchName,
348 generateThreadTitle,
349 } satisfies TextGenerationShape;
350});
351
352export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration);
apps/server/src/git/Layers/RoutingTextGeneration.ts+22−1
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18} from "../Services/TextGeneration.ts";18} from "../Services/TextGeneration.ts";
19import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";19import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
20import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";20import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
21import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";22import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
22 23
23// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
@@ -32,6 +33,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 8 unmodified lines
32 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",33 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
33) {}34) {}
34 35
36class CursorTextGen extends Context.Service<CursorTextGen, TextGenerationShape>()(
37 "t3/git/Layers/RoutingTextGeneration/CursorTextGen",
38) {}
39
35class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(40class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(
36 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",41 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
37) {}42) {}
@@ -43,10 +48,17 @@ class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationSha · 5 unmodified lines
43const makeRoutingTextGeneration = Effect.gen(function* () {48const makeRoutingTextGeneration = Effect.gen(function* () {
44 const codex = yield* CodexTextGen;49 const codex = yield* CodexTextGen;
45 const claude = yield* ClaudeTextGen;50 const claude = yield* ClaudeTextGen;
51 const cursor = yield* CursorTextGen;
46 const openCode = yield* OpenCodeTextGen;52 const openCode = yield* OpenCodeTextGen;
47 53
48 const route = (provider?: TextGenerationProvider): TextGenerationShape =>54 const route = (provider?: TextGenerationProvider): TextGenerationShape =>
49 provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex;55 provider === "claudeAgent"
56 ? claude
57 : provider === "opencode"
58 ? openCode
59 : provider === "cursor"
60 ? cursor
61 : codex;
50 62
51 return {63 return {
52 generateCommitMessage: (input) =>64 generateCommitMessage: (input) =>
@@ -73,6 +85,14 @@ const InternalClaudeLayer = Layer.effect( · 20 unmodified lines
73 }),85 }),
74).pipe(Layer.provide(ClaudeTextGenerationLive));86).pipe(Layer.provide(ClaudeTextGenerationLive));
75 87
88const InternalCursorLayer = Layer.effect(
89 CursorTextGen,
90 Effect.gen(function* () {
91 const svc = yield* TextGeneration;
92 return svc;
93 }),
94).pipe(Layer.provide(CursorTextGenerationLive));
95
76const InternalOpenCodeLayer = Layer.effect(96const InternalOpenCodeLayer = Layer.effect(
77 OpenCodeTextGen,97 OpenCodeTextGen,
78 Effect.gen(function* () {98 Effect.gen(function* () {
@@ -87,5 +107,6 @@ export const RoutingTextGenerationLive = Layer.effect( · 8 unmodified lines
87).pipe(107).pipe(
88 Layer.provide(InternalCodexLayer),108 Layer.provide(InternalCodexLayer),
89 Layer.provide(InternalClaudeLayer),109 Layer.provide(InternalClaudeLayer),
110 Layer.provide(InternalCursorLayer),
90 Layer.provide(InternalOpenCodeLayer),111 Layer.provide(InternalOpenCodeLayer),
91);112);
apps/server/src/git/Services/TextGeneration.ts+1−1
@@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; · 12 unmodified lines
13import type { TextGenerationError } from "@t3tools/contracts";13import type { TextGenerationError } from "@t3tools/contracts";
14 14
15/** Providers that support git text generation (commit messages, PR content, branch names). */15/** Providers that support git text generation (commit messages, PR content, branch names). */
16export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode";16export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
17 17
18export interface CommitMessageGenerationInput {18export interface CommitMessageGenerationInput {
19 cwd: string;19 cwd: string;