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
1111 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
1313 import { ClaudeModelSelection } from "@t3tools/contracts";
14−import { resolveApiModelId } from "@t3tools/shared/model";
1514 import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1615
1716 import { TextGenerationError } from "@t3tools/contracts";
@@ -30,6 +29,7 @@ import { · 12 unmodified lines
3029 toJsonSchemaObject,
3130 } from "../Utils.ts";
3231 import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";
32+import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts";
3333 import { ServerSettingsService } from "../../serverSettings.ts";
3434 import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";
3535
@@ -110,7 +110,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 74 unmodified lines
110110 "--json-schema",
111111 jsonSchemaStr,
112112 "--model",
113− resolveApiModelId(modelSelection),
113+ resolveClaudeApiModelId(modelSelection),
114114 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
115115 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
116116 "--dangerously-skip-permissions",
apps/server/src/git/Layers/CursorTextGeneration.test.ts+298−0
@@ -0,0 +1,298 @@
1+import * as path from "node:path";
2+import * as os from "node:os";
3+import { fileURLToPath } from "node:url";
4+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5+
6+import * as NodeServices from "@effect/platform-node/NodeServices";
7+import { it } from "@effect/vitest";
8+import { Effect, Layer } from "effect";
9+import { expect } from "vitest";
10+
11+import { ServerSettingsError } from "@t3tools/contracts";
12+
13+import { ServerConfig } from "../../config.ts";
14+import { TextGeneration } from "../Services/TextGeneration.ts";
15+import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
16+import { ServerSettingsService } from "../../serverSettings.ts";
17+
18+const __dirname = path.dirname(fileURLToPath(import.meta.url));
19+const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
20+
21+function shellSingleQuote(value: string): string {
22+ return `'${value.replaceAll("'", `'"'"'`)}'`;
23+}
24+
25+const 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+
35+function 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+
57+function 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+
99+function 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+
115+it.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 @@
1+import { Effect, Layer, Option, Ref, Schema } from "effect";
2+import { ChildProcessSpawner } from "effect/unstable/process";
3+
4+import { CursorModelSelection } from "@t3tools/contracts";
5+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6+
7+import { TextGenerationError } from "@t3tools/contracts";
8+import {
9+ type ThreadTitleGenerationResult,
10+ type TextGenerationShape,
11+ TextGeneration,
12+} from "../Services/TextGeneration.ts";
13+import {
14+ buildBranchNamePrompt,
15+ buildCommitMessagePrompt,
16+ buildPrContentPrompt,
17+ buildThreadTitlePrompt,
18+} from "../Prompts.ts";
19+import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts";
20+import {
21+ applyCursorAcpModelSelection,
22+ makeCursorAcpRuntime,
23+} from "../../provider/acp/CursorAcpSupport.ts";
24+import { ServerSettingsService } from "../../serverSettings.ts";
25+
26+const CURSOR_TIMEOUT_MS = 180_000;
27+
28+function 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+
76+function 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+
92+function 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+
101+const 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+
352+export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration);
apps/server/src/git/Layers/RoutingTextGeneration.ts+22−1
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 } from "../Services/TextGeneration.ts";
1919 import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
2020 import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21+import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
2122 import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
2223
2324 // ---------------------------------------------------------------------------
@@ -32,6 +33,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 8 unmodified lines
3233 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
3334 ) {}
3435
36+class CursorTextGen extends Context.Service<CursorTextGen, TextGenerationShape>()(
37+ "t3/git/Layers/RoutingTextGeneration/CursorTextGen",
38+) {}
39+
3540 class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(
3641 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
3742 ) {}
@@ -43,10 +48,17 @@ class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationSha · 5 unmodified lines
4348 const makeRoutingTextGeneration = Effect.gen(function* () {
4449 const codex = yield* CodexTextGen;
4550 const claude = yield* ClaudeTextGen;
51+ const cursor = yield* CursorTextGen;
4652 const openCode = yield* OpenCodeTextGen;
4753
4854 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;
5062
5163 return {
5264 generateCommitMessage: (input) =>
@@ -73,6 +85,14 @@ const InternalClaudeLayer = Layer.effect( · 20 unmodified lines
7385 }),
7486 ).pipe(Layer.provide(ClaudeTextGenerationLive));
7587
88+const InternalCursorLayer = Layer.effect(
89+ CursorTextGen,
90+ Effect.gen(function* () {
91+ const svc = yield* TextGeneration;
92+ return svc;
93+ }),
94+).pipe(Layer.provide(CursorTextGenerationLive));
95+
7696 const InternalOpenCodeLayer = Layer.effect(
7797 OpenCodeTextGen,
7898 Effect.gen(function* () {
@@ -87,5 +107,6 @@ export const RoutingTextGenerationLive = Layer.effect( · 8 unmodified lines
87107 ).pipe(
88108 Layer.provide(InternalCodexLayer),
89109 Layer.provide(InternalClaudeLayer),
110+ Layer.provide(InternalCursorLayer),
90111 Layer.provide(InternalOpenCodeLayer),
91112 );
apps/server/src/git/Services/TextGeneration.ts+1−1
@@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; · 12 unmodified lines
1313 import type { TextGenerationError } from "@t3tools/contracts";
1414
1515 /** Providers that support git text generation (commit messages, PR content, branch names). */
16−export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode";
16+export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
1717
1818 export interface CommitMessageGenerationInput {
1919 cwd: string;