diff --git a/apps/server/src/codexCatalog.test.ts b/apps/server/src/codexCatalog.test.ts new file mode 100644 index 0000000000..3636428991 --- /dev/null +++ b/apps/server/src/codexCatalog.test.ts @@ -0,0 +1,184 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { listCodexCustomPrompts, resolveCodexPromptHomePath } from "./codexCatalog"; + +const tempDirs = new Set(); +const originalCodexHome = process.env.CODEX_HOME; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.add(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.clear(); + if (typeof originalCodexHome === "string") { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + vi.restoreAllMocks(); +}); + +describe("resolveCodexPromptHomePath", () => { + it("prefers the explicit homePath input", () => { + process.env.CODEX_HOME = "/env/codex-home"; + expect(resolveCodexPromptHomePath({ homePath: "/custom/home" })).toBe( + path.resolve("/custom/home"), + ); + }); + + it("falls back to CODEX_HOME and then ~/.codex", () => { + process.env.CODEX_HOME = "/env/codex-home"; + expect(resolveCodexPromptHomePath()).toBe(path.resolve("/env/codex-home")); + + delete process.env.CODEX_HOME; + vi.spyOn(os, "homedir").mockReturnValue("/Users/tester"); + expect(resolveCodexPromptHomePath()).toBe(path.resolve("/Users/tester/.codex")); + }); +}); + +describe("listCodexCustomPrompts", () => { + it("discovers top-level markdown prompts, parses frontmatter, and sorts by name", async () => { + const codexHome = makeTempDir("t3code-codex-prompts-"); + const promptsDir = path.join(codexHome, "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync( + path.join(promptsDir, "beta.md"), + ["---", "description: Beta prompt", "argument-hint: FILE=", "---", "Review $FILE"].join("\n"), + "utf8", + ); + fs.writeFileSync(path.join(promptsDir, "alpha.md"), "Summarize $1", "utf8"); + fs.mkdirSync(path.join(promptsDir, "nested"), { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "nested", "ignored.md"), "Ignore me", "utf8"); + + await expect(listCodexCustomPrompts({ homePath: codexHome })).resolves.toEqual({ + prompts: [ + { + name: "alpha", + content: "Summarize $1", + }, + { + name: "beta", + description: "Beta prompt", + argumentHint: "FILE=", + content: "Review $FILE", + }, + ], + }); + }); + + it("loads project-local .codex/prompts and prefers them over global prompts", async () => { + const projectRoot = makeTempDir("t3code-project-prompts-"); + const projectPromptsDir = path.join(projectRoot, ".codex", "prompts"); + fs.mkdirSync(projectPromptsDir, { recursive: true }); + fs.writeFileSync( + path.join(projectPromptsDir, "review.md"), + ["---", "description: Project review prompt", "---", "Project review $FILE"].join("\n"), + "utf8", + ); + + const codexHome = makeTempDir("t3code-global-prompts-"); + const globalPromptsDir = path.join(codexHome, "prompts"); + fs.mkdirSync(globalPromptsDir, { recursive: true }); + fs.writeFileSync( + path.join(globalPromptsDir, "review.md"), + ["---", "description: Global review prompt", "---", "Global review $FILE"].join("\n"), + "utf8", + ); + fs.writeFileSync(path.join(globalPromptsDir, "summarize.md"), "Summarize $1", "utf8"); + + await expect( + listCodexCustomPrompts({ homePath: codexHome, projectPath: projectRoot }), + ).resolves.toEqual({ + prompts: [ + { + name: "review", + description: "Project review prompt", + content: "Project review $FILE", + }, + { + name: "summarize", + content: "Summarize $1", + }, + ], + }); + }); + + it("expands ~ in projectPath before resolving .codex/prompts", async () => { + const fakeHome = makeTempDir("t3code-codex-project-home-"); + const projectRoot = path.join(fakeHome, "project"); + const projectPromptsDir = path.join(projectRoot, ".codex", "prompts"); + const codexHome = makeTempDir("t3code-codex-project-global-"); + fs.mkdirSync(projectPromptsDir, { recursive: true }); + fs.writeFileSync(path.join(projectPromptsDir, "review.md"), "Review $FILE", "utf8"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + await expect( + listCodexCustomPrompts({ homePath: codexHome, projectPath: "~/project" }), + ).resolves.toEqual({ + prompts: [{ name: "review", content: "Review $FILE" }], + }); + }); + + it("uses CODEX_HOME when explicit input is missing", async () => { + const codexHome = makeTempDir("t3code-codex-prompts-env-"); + const promptsDir = path.join(codexHome, "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "env.md"), "From env", "utf8"); + process.env.CODEX_HOME = codexHome; + + await expect(listCodexCustomPrompts()).resolves.toEqual({ + prompts: [{ name: "env", content: "From env" }], + }); + }); + + it("falls back to ~/.codex when CODEX_HOME is unset", async () => { + const fakeHome = makeTempDir("t3code-codex-home-"); + const codexHome = path.join(fakeHome, ".codex"); + const promptsDir = path.join(codexHome, "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "default.md"), "Default prompt", "utf8"); + delete process.env.CODEX_HOME; + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + await expect(listCodexCustomPrompts()).resolves.toEqual({ + prompts: [{ name: "default", content: "Default prompt" }], + }); + }); + + it("skips invalid prompt files instead of failing the whole result", async () => { + const codexHome = makeTempDir("t3code-codex-prompts-invalid-"); + const promptsDir = path.join(codexHome, "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "good.md"), "Good prompt", "utf8"); + fs.writeFileSync(path.join(promptsDir, "broken.md"), "---\ndescription: Missing end", "utf8"); + + await expect(listCodexCustomPrompts({ homePath: codexHome })).resolves.toEqual({ + prompts: [{ name: "good", content: "Good prompt" }], + }); + }); + + it("accepts empty frontmatter blocks", async () => { + const codexHome = makeTempDir("t3code-codex-prompts-empty-frontmatter-"); + const promptsDir = path.join(codexHome, "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync( + path.join(promptsDir, "empty.md"), + ["---", "---", "Prompt body"].join("\n"), + "utf8", + ); + + await expect(listCodexCustomPrompts({ homePath: codexHome })).resolves.toEqual({ + prompts: [{ name: "empty", content: "Prompt body" }], + }); + }); +}); diff --git a/apps/server/src/codexCatalog.ts b/apps/server/src/codexCatalog.ts new file mode 100644 index 0000000000..e373ef84c6 --- /dev/null +++ b/apps/server/src/codexCatalog.ts @@ -0,0 +1,181 @@ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { + CodexCustomPrompt, + CodexListCustomPromptsInput, + CodexListCustomPromptsResult, +} from "@t3tools/contracts"; + +function resolveHomePathSegment(input: string): string { + if (input === "~") { + return os.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(os.homedir(), input.slice(2)); + } + return input; +} + +function stripMatchingQuotes(input: string): string { + const trimmed = input.trim(); + if (trimmed.length < 2) { + return trimmed; + } + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function parsePromptFrontmatter(fileContents: string): { + description?: string; + argumentHint?: string; + content: string; +} | null { + const normalized = fileContents.replace(/^\uFEFF/, ""); + if (!normalized.startsWith("---")) { + return { content: normalized }; + } + + const delimiterMatch = /^---\r?\n([\s\S]*?)\r?\n?---(?:\r?\n|$)/.exec(normalized); + if (!delimiterMatch) { + return null; + } + + const frontmatterBlock = delimiterMatch[1] ?? ""; + const bodyStart = delimiterMatch[0].length; + let description: string | undefined; + let argumentHint: string | undefined; + + for (const line of frontmatterBlock.split(/\r?\n/)) { + const separatorIndex = line.indexOf(":"); + if (separatorIndex === -1) { + continue; + } + const rawKey = line.slice(0, separatorIndex).trim().toLowerCase(); + const rawValue = stripMatchingQuotes(line.slice(separatorIndex + 1)); + if (!rawValue) { + continue; + } + if (rawKey === "description") { + description = rawValue; + continue; + } + if (rawKey === "argument-hint" || rawKey === "argument_hint") { + argumentHint = rawValue; + } + } + + return { + ...(description ? { description } : {}), + ...(argumentHint ? { argumentHint } : {}), + content: normalized.slice(bodyStart), + }; +} + +export function resolveCodexPromptHomePath( + input?: Pick, +): string { + const homePath = input?.homePath?.trim() || process.env.CODEX_HOME?.trim() || "~/.codex"; + return path.resolve(resolveHomePathSegment(homePath)); +} + +function resolveProjectPromptDir( + input?: Pick, +): string | null { + const projectPath = input?.projectPath?.trim(); + if (!projectPath) { + return null; + } + return path.resolve(resolveHomePathSegment(projectPath), ".codex", "prompts"); +} + +async function readPromptDirectory(promptDir: string): Promise { + let entries: Dirent[]; + try { + entries = await fs.readdir(promptDir, { withFileTypes: true, encoding: "utf8" }); + } catch { + return []; + } + + const prompts = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md")) + .map(async (entry): Promise => { + const promptName = entry.name.slice(0, -3).trim(); + if (!promptName) { + return null; + } + const filePath = path.join(promptDir, entry.name); + try { + const fileContents = await fs.readFile(filePath, "utf8"); + const parsed = parsePromptFrontmatter(fileContents); + if (!parsed) { + return null; + } + if (parsed.description && parsed.argumentHint) { + return { + name: promptName, + description: parsed.description, + argumentHint: parsed.argumentHint, + content: parsed.content, + }; + } + if (parsed.description) { + return { + name: promptName, + description: parsed.description, + content: parsed.content, + }; + } + if (parsed.argumentHint) { + return { + name: promptName, + argumentHint: parsed.argumentHint, + content: parsed.content, + }; + } + return { + name: promptName, + content: parsed.content, + }; + } catch { + return null; + } + }), + ); + + return prompts.filter((prompt): prompt is CodexCustomPrompt => prompt !== null); +} + +export async function listCodexCustomPrompts( + input?: CodexListCustomPromptsInput, +): Promise { + const projectPromptDir = resolveProjectPromptDir(input); + const globalPromptDir = path.join(resolveCodexPromptHomePath(input), "prompts"); + const [projectPrompts, globalPrompts] = await Promise.all([ + projectPromptDir ? readPromptDirectory(projectPromptDir) : Promise.resolve([]), + readPromptDirectory(globalPromptDir), + ]); + + const promptsByName = new Map(); + for (const prompt of projectPrompts) { + promptsByName.set(prompt.name, prompt); + } + for (const prompt of globalPrompts) { + if (!promptsByName.has(prompt.name)) { + promptsByName.set(prompt.name, prompt); + } + } + + return { + prompts: Array.from(promptsByName.values()).toSorted((left, right) => + left.name.localeCompare(right.name), + ), + }; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a318..2ed1fc50e2 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1573,6 +1573,52 @@ describe("WebSocket Server", () => { }); }); + it("supports codex.listCustomPrompts", async () => { + const projectRoot = makeTempDir("t3code-ws-project-prompts-"); + const projectPromptsDir = path.join(projectRoot, ".codex", "prompts"); + fs.mkdirSync(projectPromptsDir, { recursive: true }); + fs.writeFileSync( + path.join(projectPromptsDir, "project-review.md"), + "Project review $FILE", + "utf8", + ); + + const codexHome = makeTempDir("t3code-ws-codex-prompts-"); + const promptsDir = path.join(codexHome, "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync( + path.join(promptsDir, "review.md"), + ["---", "description: Review prompt", "---", "Review $FILE"].join("\n"), + "utf8", + ); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.codexListCustomPrompts, { + homePath: codexHome, + projectPath: projectRoot, + }); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + prompts: [ + { + name: "project-review", + content: "Project review $FILE", + }, + { + name: "review", + description: "Review prompt", + content: "Review $FILE", + }, + ], + }); + }); + it("supports projects.writeFile within the workspace root", async () => { const workspace = makeTempDir("t3code-ws-write-file-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..c27094e3cb 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -78,6 +78,7 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { listCodexCustomPrompts } from "./codexCatalog"; /** * ServerShape - Service API for server lifecycle control. @@ -781,6 +782,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* openInEditor(body); } + case WS_METHODS.codexListCustomPrompts: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise({ + try: () => listCodexCustomPrompts(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to list Codex custom prompts: ${String(cause)}`, + }), + }); + } + case WS_METHODS.gitStatus: { const body = stripRequestTag(request.body); return yield* gitManager.status(body); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51b..93644837ee 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { + type CodexCustomPrompt, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -41,10 +42,38 @@ interface WsRequestEnvelope { }; } +function isTurnStartRequestBody( + request: WsRequestEnvelope["body"], +): request is WsRequestEnvelope["body"] & { + command: { type: "thread.turn.start"; message: { text: string } }; +} { + return ( + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + !!request.command && + typeof request.command === "object" && + "type" in request.command && + request.command.type === "thread.turn.start" && + "message" in request.command && + !!request.command.message && + typeof request.command.message === "object" && + "text" in request.command.message && + typeof request.command.message.text === "string" + ); +} + +function countCodexPromptRequests(): number { + return wsRequests.filter((request) => request._tag === WS_METHODS.codexListCustomPrompts).length; +} + +function latestCodexPromptRequest(): WsRequestEnvelope["body"] | undefined { + return wsRequests.findLast((request) => request._tag === WS_METHODS.codexListCustomPrompts); +} + interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; welcome: WsWelcomePayload; + customPrompts: CodexCustomPrompt[]; } let fixture: TestFixture; @@ -247,6 +276,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { bootstrapProjectId: PROJECT_ID, bootstrapThreadId: THREAD_ID, }, + customPrompts: [], }; } @@ -364,6 +394,11 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.codexListCustomPrompts) { + return { + prompts: fixture.customPrompts, + }; + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -487,6 +522,33 @@ async function waitForProductionStyles(): Promise { ); } +async function dispatchActiveKey(key: string): Promise { + const activeElement = document.activeElement as HTMLElement | null; + if (!activeElement) { + throw new Error(`Unable to dispatch ${key}: no active element.`); + } + activeElement.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }), + ); + activeElement.dispatchEvent( + new KeyboardEvent("keyup", { + key, + bubbles: true, + cancelable: true, + }), + ); + await waitForLayout(); +} + +function readSelectionOffset(): number | null { + const selection = document.getSelection(); + return selection ? selection.anchorOffset : null; +} + async function waitForElement( query: () => T | null, errorMessage: string, @@ -1247,4 +1309,217 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("lists custom prompts, inserts prompt args, tabs between them, and expands before send", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-custom-prompt-test" as MessageId, + targetText: "custom prompt test", + }), + configureFixture: (nextFixture) => { + nextFixture.customPrompts = [ + { + name: "review", + description: "Review a file with a priority", + content: "Review $FILE with priority $LEVEL", + }, + ]; + }, + }); + + try { + await vi.waitFor( + () => { + expect(countCodexPromptRequests()).toBe(1); + expect(latestCodexPromptRequest()).toEqual( + expect.objectContaining({ + _tag: WS_METHODS.codexListCustomPrompts, + projectPath: "/repo/project", + }), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + const composer = page.getByTestId("composer-editor"); + await expect.element(composer).toBeInTheDocument(); + await composer.click(); + await composer.fill("/prompts:rev"); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("/prompts:review"); + expect(document.body.textContent).toContain("Review a file with a priority"); + expect(countCodexPromptRequests()).toBe(1); + }, + { timeout: 8_000, interval: 16 }, + ); + + await dispatchActiveKey("Tab"); + + await vi.waitFor( + () => { + expect(document.querySelector('[data-testid="composer-editor"]')?.textContent).toBe( + '/prompts:review FILE="" LEVEL=""', + ); + expect(readSelectionOffset()).toBe('/prompts:review FILE="'.length); + }, + { timeout: 8_000, interval: 16 }, + ); + + await dispatchActiveKey("Tab"); + + await vi.waitFor( + () => { + expect(readSelectionOffset()).toBe('/prompts:review FILE="" LEVEL="'.length); + }, + { timeout: 8_000, interval: 16 }, + ); + + await composer.fill('/prompts:review FILE="src/app.ts" LEVEL="high"'); + await dispatchActiveKey("Enter"); + + await vi.waitFor( + () => { + const turnStartRequest = wsRequests.find(isTurnStartRequestBody); + expect(turnStartRequest).toBeTruthy(); + expect(turnStartRequest?.command.message.text).toBe( + "Review src/app.ts with priority high", + ); + expect(document.body.textContent).toContain("Review src/app.ts with priority high"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("sends zero-argument custom prompt content immediately on selection", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-custom-prompt-direct-send" as MessageId, + targetText: "custom prompt direct send test", + }), + configureFixture: (nextFixture) => { + nextFixture.customPrompts = [ + { + name: "status", + description: "Summarize the current project status", + content: "Summarize the current project status.", + }, + ]; + }, + }); + + try { + await vi.waitFor( + () => { + expect(countCodexPromptRequests()).toBe(1); + expect(latestCodexPromptRequest()).toEqual( + expect.objectContaining({ + _tag: WS_METHODS.codexListCustomPrompts, + projectPath: "/repo/project", + }), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + const composer = page.getByTestId("composer-editor"); + await expect.element(composer).toBeInTheDocument(); + await composer.click(); + await composer.fill("/prompts:sta"); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("/prompts:status"); + expect(document.body.textContent).toContain("Summarize the current project status"); + }, + { timeout: 8_000, interval: 16 }, + ); + + await dispatchActiveKey("Tab"); + + await vi.waitFor( + () => { + const turnStartRequest = wsRequests.find(isTurnStartRequestBody); + expect(turnStartRequest).toBeTruthy(); + expect(turnStartRequest?.command.message.text).toBe( + "Summarize the current project status.", + ); + expect(document.body.textContent).toContain("Summarize the current project status."); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("blocks malformed custom prompt args and keeps the draft intact", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-custom-prompt-error-test" as MessageId, + targetText: "custom prompt error test", + }), + configureFixture: (nextFixture) => { + nextFixture.customPrompts = [ + { + name: "review", + content: "Review $FILE", + }, + ]; + }, + }); + + try { + await vi.waitFor( + () => { + expect(countCodexPromptRequests()).toBe(1); + expect(latestCodexPromptRequest()).toEqual( + expect.objectContaining({ + _tag: WS_METHODS.codexListCustomPrompts, + projectPath: "/repo/project", + }), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + const composer = page.getByTestId("composer-editor"); + await expect.element(composer).toBeInTheDocument(); + await composer.click(); + await composer.fill("/prompts:rev"); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("/prompts:review"); + }, + { timeout: 8_000, interval: 16 }, + ); + + await composer.fill("/prompts:review src/app.ts"); + await dispatchActiveKey("Enter"); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain( + "Could not parse /prompts:review: expected key=value but found 'src/app.ts'.", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect(wsRequests.some(isTurnStartRequestBody)).toBe(false); + expect(document.querySelector('[data-testid="composer-editor"]')?.textContent).toBe( + "/prompts:review src/app.ts", + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e6..52141774f0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + type CodexCustomPrompt, DEFAULT_MODEL_BY_PROVIDER, type EditorId, type KeybindingCommand, @@ -21,6 +22,12 @@ import { RuntimeMode, ProviderInteractionMode, } from "@t3tools/contracts"; +import { + buildCustomPromptInsertText, + expandCustomPromptInvocation, + findNextCustomPromptArgCursor, + getCustomPromptArgumentHint, +} from "@t3tools/shared/codex"; import { getDefaultModel, getDefaultReasoningEffort, @@ -33,6 +40,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { codexCustomPromptsQueryOptions } from "~/lib/codexReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; @@ -142,6 +150,7 @@ import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu" import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { CodexPromptDebugBanner } from "./chat/CodexPromptDebugBanner"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { @@ -169,6 +178,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; +const EMPTY_CODEX_CUSTOM_PROMPTS: CodexCustomPrompt[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; @@ -517,6 +527,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; + const effectiveCodexHomePath = + settings.codexHomePath && settings.codexHomePath.trim().length > 0 + ? settings.codexHomePath.trim() + : null; const selectedModelOptionsForDispatch = useMemo(() => { if (selectedProvider !== "codex") { return undefined; @@ -528,16 +542,16 @@ export default function ChatView({ threadId }: ChatViewProps) { return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); const providerOptionsForDispatch = useMemo(() => { - if (!settings.codexBinaryPath && !settings.codexHomePath) { + if (!settings.codexBinaryPath && !effectiveCodexHomePath) { return undefined; } return { codex: { ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), + ...(effectiveCodexHomePath ? { homePath: effectiveCodexHomePath } : {}), }, }; - }, [settings.codexBinaryPath, settings.codexHomePath]); + }, [effectiveCodexHomePath, settings.codexBinaryPath]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), @@ -914,6 +928,36 @@ export default function ChatView({ threadId }: ChatViewProps) { const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const activeProjectId = activeProject?.id ?? null; + const activeProjectPath = activeProject?.cwd ?? null; + const shouldLoadCodexCustomPrompts = selectedProvider === "codex" && activeProjectId !== null; + const codexCustomPromptsQuery = useQuery( + codexCustomPromptsQueryOptions({ + enabled: shouldLoadCodexCustomPrompts, + projectId: activeProjectId, + projectPath: activeProjectPath, + homePath: effectiveCodexHomePath, + }), + ); + useEffect(() => { + if (!shouldLoadCodexCustomPrompts) { + return; + } + void queryClient.prefetchQuery( + codexCustomPromptsQueryOptions({ + enabled: true, + projectId: activeProjectId, + projectPath: activeProjectPath, + homePath: effectiveCodexHomePath, + }), + ); + }, [ + activeProjectId, + activeProjectPath, + effectiveCodexHomePath, + queryClient, + shouldLoadCodexCustomPrompts, + ]); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -923,6 +967,13 @@ export default function ChatView({ threadId }: ChatViewProps) { }), ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const codexCustomPrompts = codexCustomPromptsQuery.data?.prompts ?? EMPTY_CODEX_CUSTOM_PROMPTS; + const codexCustomPromptsErrorMessage = + codexCustomPromptsQuery.error instanceof Error + ? codexCustomPromptsQuery.error.message + : codexCustomPromptsQuery.error + ? String(codexCustomPromptsQuery.error) + : null; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -961,12 +1012,38 @@ export default function ChatView({ threadId }: ChatViewProps) { }, ] satisfies ReadonlyArray>; const query = composerTrigger.query.trim().toLowerCase(); - if (!query) { - return [...slashCommandItems]; - } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), - ); + const matchingSlashCommandItems = !query + ? [...slashCommandItems] + : slashCommandItems.filter( + (item) => item.command.includes(query) || item.label.slice(1).includes(query), + ); + const promptItems = + selectedProvider !== "codex" + ? [] + : codexCustomPrompts + .toSorted((left, right) => left.name.localeCompare(right.name)) + .filter((prompt) => { + if (!query) { + return true; + } + const normalizedName = prompt.name.toLowerCase(); + const normalizedCommand = `prompts:${normalizedName}`; + const normalizedDescription = prompt.description?.toLowerCase() ?? ""; + return ( + normalizedName.includes(query) || + normalizedCommand.includes(query) || + normalizedDescription.includes(query) + ); + }) + .map((prompt) => ({ + id: `prompt:${prompt.name}`, + type: "prompt" as const, + prompt, + label: `/prompts:${prompt.name}`, + description: + prompt.description ?? getCustomPromptArgumentHint(prompt) ?? "Custom prompt", + })); + return [...matchingSlashCommandItems, ...promptItems]; } return searchableModelOptions @@ -985,7 +1062,13 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} ยท ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [ + codexCustomPrompts, + composerTrigger, + searchableModelOptions, + selectedProvider, + workspaceEntries, + ]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -2204,7 +2287,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const trimmed = prompt.trim(); + const trimmed = promptRef.current.trim(); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -2232,6 +2315,15 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger(null); return; } + const customPromptExpansion = + selectedProvider === "codex" + ? expandCustomPromptInvocation(trimmed, codexCustomPrompts) + : null; + if (customPromptExpansion && "error" in customPromptExpansion) { + setStoreThreadError(activeThread.id, customPromptExpansion.error); + return; + } + const textForSend = customPromptExpansion?.expanded ?? trimmed; if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const threadIdForSend = activeThread.id; @@ -2281,7 +2373,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: textForSend, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2335,7 +2427,7 @@ export default function ChatView({ threadId }: ChatViewProps) { firstComposerImageName = firstComposerImage.name; } } - let titleSeed = trimmed; + let titleSeed = textForSend; if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; @@ -2419,7 +2511,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: textForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, attachments: turnAttachments, }, model: selectedModel || undefined, @@ -2924,7 +3016,7 @@ export default function ChatView({ threadId }: ChatViewProps) { rangeStart: number, rangeEnd: number, replacement: string, - options?: { expectedText?: string }, + options?: { expectedText?: string; cursorOffset?: number }, ): boolean => { const currentText = promptRef.current; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); @@ -2935,7 +3027,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return false; } - const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const next = replaceTextRange( + promptRef.current, + rangeStart, + rangeEnd, + replacement, + options?.cursorOffset, + ); const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; @@ -3047,6 +3145,34 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.type === "prompt") { + const promptArgumentHint = getCustomPromptArgumentHint(item.prompt); + if (!promptArgumentHint && composerImages.length === 0) { + promptRef.current = item.prompt.content; + setPrompt(item.prompt.content); + setComposerCursor(item.prompt.content.length); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + composerFormRef.current?.requestSubmit(); + return; + } + const replacement = buildCustomPromptInsertText(item.prompt); + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + replacement.text, + { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + ...(typeof replacement.cursorOffset === "number" + ? { cursorOffset: replacement.cursorOffset } + : {}), + }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -3057,9 +3183,11 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ applyPromptReplacement, + composerImages.length, handleInteractionModeChange, onProviderModelSelect, resolveActiveComposerTrigger, + setPrompt, ], ); const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { @@ -3084,10 +3212,13 @@ export default function ChatView({ threadId }: ChatViewProps) { [composerHighlightedItemId, composerMenuItems], ); const isComposerMenuLoading = - composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || - workspaceEntriesQuery.isLoading || - workspaceEntriesQuery.isFetching); + (composerTriggerKind === "path" && + ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || + workspaceEntriesQuery.isLoading || + workspaceEntriesQuery.isFetching)) || + (composerTriggerKind === "slash-command" && + shouldLoadCodexCustomPrompts && + (codexCustomPromptsQuery.isLoading || codexCustomPromptsQuery.isFetching)); const onPromptChange = useCallback( ( @@ -3152,6 +3283,23 @@ export default function ChatView({ threadId }: ChatViewProps) { } } + if (key === "Tab") { + const snapshot = readComposerSnapshot(); + const nextExpandedCursor = findNextCustomPromptArgCursor( + snapshot.value, + snapshot.expandedCursor, + ); + if (nextExpandedCursor !== null) { + const nextCursor = collapseExpandedComposerCursor(snapshot.value, nextExpandedCursor); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(snapshot.value, nextExpandedCursor)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCursor); + }); + return true; + } + } + if (key === "Enter" && !event.shiftKey) { void onSend(); return true; @@ -3257,6 +3405,17 @@ export default function ChatView({ threadId }: ChatViewProps) { error={activeThread.error} onDismiss={() => setThreadError(activeThread.id, null)} /> + {/* Main content area with optional plan sidebar */}
{/* Chat column */} diff --git a/apps/web/src/components/chat/CodexPromptDebugBanner.tsx b/apps/web/src/components/chat/CodexPromptDebugBanner.tsx new file mode 100644 index 0000000000..f48a522857 --- /dev/null +++ b/apps/web/src/components/chat/CodexPromptDebugBanner.tsx @@ -0,0 +1,56 @@ +import { memo } from "react"; +import { BugIcon } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; + +function shouldShowCodexPromptDebugBanner(): boolean { + if (!import.meta.env.DEV || typeof window === "undefined") { + return false; + } + const searchParams = new URLSearchParams(window.location.search); + if (searchParams.get("debugCodexPrompts") === "1") { + return true; + } + return window.localStorage.getItem("t3code.debug.codexPrompts") === "1"; +} + +export const CodexPromptDebugBanner = memo(function CodexPromptDebugBanner(props: { + provider: string; + activeProjectId: string | null; + activeProjectPath: string | null; + effectiveCodexHomePath: string | null; + shouldLoad: boolean; + queryStatus: string; + fetchStatus: string; + promptCount: number; + errorMessage: string | null; +}) { + const globalPromptDir = `${props.effectiveCodexHomePath ?? "~/.codex"}/prompts`; + const projectPromptDir = props.activeProjectPath + ? `${props.activeProjectPath}/.codex/prompts` + : null; + + if (!shouldShowCodexPromptDebugBanner()) { + return null; + } + + return ( +
+ + + Custom prompt debug + +
provider={props.provider}
+
activeProjectId={props.activeProjectId ?? ""}
+
activeProjectPath={props.activeProjectPath ?? ""}
+
projectPromptDir={projectPromptDir ?? ""}
+
globalPromptDir={globalPromptDir}
+
shouldLoad={String(props.shouldLoad)}
+
queryStatus={props.queryStatus}
+
fetchStatus={props.fetchStatus}
+
promptCount={String(props.promptCount)}
+ {props.errorMessage ?
error={props.errorMessage}
: null} +
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 818c3c20f8..03b583494f 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,7 +1,12 @@ -import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { + type CodexCustomPrompt, + type ProjectEntry, + type ModelSlug, + type ProviderKind, +} from "@t3tools/contracts"; import { memo } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; -import { BotIcon } from "lucide-react"; +import { BotIcon, ScrollTextIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandItem, CommandList } from "../ui/command"; @@ -23,6 +28,13 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "prompt"; + prompt: CodexCustomPrompt; + label: string; + description: string; + } | { id: string; type: "model"; @@ -65,10 +77,14 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {props.items.length === 0 && (

{props.isLoading - ? "Searching workspace files..." + ? props.triggerKind === "path" + ? "Searching workspace files..." + : "Loading custom prompts..." : props.triggerKind === "path" ? "No matching files or folders." - : "No matching command."} + : props.triggerKind === "slash-command" + ? "No matching command or prompt." + : "No matching model."}

)}
@@ -106,6 +122,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "slash-command" ? ( ) : null} + {props.item.type === "prompt" ? ( + + ) : null} {props.item.type === "model" ? ( model diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 36532e9044..fa58fd0ee9 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -59,6 +59,18 @@ describe("detectComposerTrigger", () => { }); }); + it("detects arbitrary slash commands so custom prompts can autocomplete", () => { + const text = "/prompts:rev"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-command", + query: "prompts:rev", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + it("detects @path trigger in the middle of existing text", () => { // User typed @ between "inspect " and "in this sentence" const text = "Please inspect @in this sentence"; @@ -108,6 +120,20 @@ describe("replaceTextRange", () => { cursor: 6, }); }); + + it("supports placing the cursor inside inserted text", () => { + const replaced = replaceTextRange( + "", + 0, + 0, + '/prompts:review FILE=""', + '/prompts:review FILE="'.length, + ); + expect(replaced).toEqual({ + text: '/prompts:review FILE=""', + cursor: '/prompts:review FILE="'.length, + }); + }); }); describe("expandCollapsedComposerCursor", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index b696d80381..bf4a02cea0 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -10,8 +10,6 @@ export interface ComposerTrigger { rangeEnd: number; } -const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; - function clampCursor(text: string, cursor: number): number { if (!Number.isFinite(cursor)) return text.length; return Math.max(0, Math.min(text.length, Math.floor(cursor))); @@ -168,15 +166,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { - return { - kind: "slash-command", - query: commandQuery, - rangeStart: lineStart, - rangeEnd: cursor, - }; - } - return null; + return { + kind: "slash-command", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; } const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); @@ -221,9 +216,14 @@ export function replaceTextRange( rangeStart: number, rangeEnd: number, replacement: string, + cursorOffset?: number, ): { text: string; cursor: number } { const safeStart = Math.max(0, Math.min(text.length, rangeStart)); const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; - return { text: nextText, cursor: safeStart + replacement.length }; + const nextCursorOffset = + typeof cursorOffset === "number" + ? Math.max(0, Math.min(replacement.length, Math.floor(cursorOffset))) + : replacement.length; + return { text: nextText, cursor: safeStart + nextCursorOffset }; } diff --git a/apps/web/src/lib/codexReactQuery.test.ts b/apps/web/src/lib/codexReactQuery.test.ts new file mode 100644 index 0000000000..e7a76787b1 --- /dev/null +++ b/apps/web/src/lib/codexReactQuery.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { codexCustomPromptsQueryOptions, codexQueryKeys } from "./codexReactQuery"; + +describe("codexQueryKeys.customPrompts", () => { + it("scopes prompt cache keys by project path and Codex home path", () => { + expect(codexQueryKeys.customPrompts("project-a", "/repo/a", "/home/a")).not.toEqual( + codexQueryKeys.customPrompts("project-b", "/repo/a", "/home/a"), + ); + expect(codexQueryKeys.customPrompts("project-a", "/repo/a", "/home/a")).not.toEqual( + codexQueryKeys.customPrompts("project-a", "/repo/b", "/home/a"), + ); + expect(codexQueryKeys.customPrompts("project-a", "/repo/a", "/home/a")).not.toEqual( + codexQueryKeys.customPrompts("project-a", "/repo/a", "/home/b"), + ); + }); +}); + +describe("codexCustomPromptsQueryOptions", () => { + it("attaches the project-scoped cache key", () => { + const options = codexCustomPromptsQueryOptions({ + enabled: true, + projectId: "project-a", + projectPath: "/repo/project-a", + homePath: "/home/a", + }); + + expect(options.queryKey).toEqual( + codexQueryKeys.customPrompts("project-a", "/repo/project-a", "/home/a"), + ); + }); +}); diff --git a/apps/web/src/lib/codexReactQuery.ts b/apps/web/src/lib/codexReactQuery.ts new file mode 100644 index 0000000000..49c4bab1bb --- /dev/null +++ b/apps/web/src/lib/codexReactQuery.ts @@ -0,0 +1,30 @@ +import { queryOptions } from "@tanstack/react-query"; +import { ensureNativeApi } from "~/nativeApi"; + +export const codexQueryKeys = { + all: ["codex"] as const, + customPrompts: (projectId: string | null, projectPath: string | null, homePath: string | null) => + ["codex", "custom-prompts", projectId, projectPath, homePath] as const, +}; + +export function codexCustomPromptsQueryOptions(input: { + enabled: boolean; + projectId: string | null; + projectPath: string | null; + homePath: string | null; +}) { + return queryOptions({ + queryKey: codexQueryKeys.customPrompts(input.projectId, input.projectPath, input.homePath), + queryFn: async () => { + const api = ensureNativeApi(); + return api.codex.listCustomPrompts({ + ...(input.homePath ? { homePath: input.homePath } : {}), + ...(input.projectPath ? { projectPath: input.projectPath } : {}), + }); + }, + enabled: input.enabled, + staleTime: 30_000, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }); +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde69..825de1713f 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -132,6 +132,9 @@ export function createWsNativeApi(): NativeApi { window.open(url, "_blank", "noopener,noreferrer"); }, }, + codex: { + listCustomPrompts: (input) => transport.request(WS_METHODS.codexListCustomPrompts, input), + }, git: { pull: (input) => transport.request(WS_METHODS.gitPull, input), status: (input) => transport.request(WS_METHODS.gitStatus, input), diff --git a/packages/contracts/src/codex.ts b/packages/contracts/src/codex.ts new file mode 100644 index 0000000000..045dc8da71 --- /dev/null +++ b/packages/contracts/src/codex.ts @@ -0,0 +1,21 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const CodexCustomPrompt = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + argumentHint: Schema.optional(TrimmedNonEmptyString), + content: Schema.String, +}); +export type CodexCustomPrompt = typeof CodexCustomPrompt.Type; + +export const CodexListCustomPromptsInput = Schema.Struct({ + homePath: Schema.optional(TrimmedNonEmptyString), + projectPath: Schema.optional(TrimmedNonEmptyString), +}); +export type CodexListCustomPromptsInput = typeof CodexListCustomPromptsInput.Type; + +export const CodexListCustomPromptsResult = Schema.Struct({ + prompts: Schema.Array(CodexCustomPrompt), +}); +export type CodexListCustomPromptsResult = typeof CodexListCustomPromptsResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..fd21563d98 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,7 @@ export * from "./terminal"; export * from "./provider"; export * from "./providerRuntime"; export * from "./model"; +export * from "./codex"; export * from "./ws"; export * from "./keybindings"; export * from "./server"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..7e495037e3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,3 +1,4 @@ +import type { CodexListCustomPromptsInput, CodexListCustomPromptsResult } from "./codex"; import type { GitCheckoutInput, GitCreateBranchInput, @@ -150,6 +151,11 @@ export interface NativeApi { status: (input: GitStatusInput) => Promise; runStackedAction: (input: GitRunStackedActionInput) => Promise; }; + codex: { + listCustomPrompts: ( + input?: CodexListCustomPromptsInput, + ) => Promise; + }; contextMenu: { show: ( items: readonly ContextMenuItem[], diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b8..165dc3dda9 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -1,5 +1,6 @@ import { Schema, Struct } from "effect"; import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; +import { CodexListCustomPromptsInput } from "./codex"; import { ClientOrchestrationCommand, @@ -51,6 +52,9 @@ export const WS_METHODS = { // Shell methods shellOpenInEditor: "shell.openInEditor", + // Codex methods + codexListCustomPrompts: "codex.listCustomPrompts", + // Git methods gitPull: "git.pull", gitStatus: "git.status", @@ -115,6 +119,9 @@ const WebSocketRequestBody = Schema.Union([ // Shell methods tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), + // Codex methods + tagRequestBody(WS_METHODS.codexListCustomPrompts, CodexListCustomPromptsInput), + // Git methods tagRequestBody(WS_METHODS.gitPull, GitPullInput), tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), diff --git a/packages/shared/package.json b/packages/shared/package.json index 02ae794d64..461157a450 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -20,6 +20,10 @@ "types": "./src/shell.ts", "import": "./src/shell.ts" }, + "./codex": { + "types": "./src/codex.ts", + "import": "./src/codex.ts" + }, "./Net": { "types": "./src/Net.ts", "import": "./src/Net.ts" diff --git a/packages/shared/src/codex.test.ts b/packages/shared/src/codex.test.ts new file mode 100644 index 0000000000..55f9037069 --- /dev/null +++ b/packages/shared/src/codex.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; + +import type { CodexCustomPrompt } from "@t3tools/contracts"; +import { + buildCustomPromptInsertText, + expandCustomPromptInvocation, + findNextCustomPromptArgCursor, + getCustomPromptArgumentHint, +} from "./codex"; + +function makePrompt( + input: Partial & Pick, +): CodexCustomPrompt { + return { + name: input.name, + content: input.content, + ...(input.description ? { description: input.description } : {}), + ...(input.argumentHint ? { argumentHint: input.argumentHint } : {}), + }; +} + +describe("getCustomPromptArgumentHint", () => { + it("prefers explicit argument hints", () => { + expect( + getCustomPromptArgumentHint( + makePrompt({ + name: "review", + argumentHint: "FILE= LEVEL=", + content: "Review $FILE at $LEVEL", + }), + ), + ).toBe("FILE= LEVEL="); + }); + + it("infers named argument hints from placeholders", () => { + expect( + getCustomPromptArgumentHint( + makePrompt({ + name: "review", + content: "Review $FILE at $LEVEL", + }), + ), + ).toBe("FILE= LEVEL="); + }); + + it("returns [args] for positional prompts", () => { + expect( + getCustomPromptArgumentHint( + makePrompt({ + name: "summarize", + content: "Summarize $1 and $ARGUMENTS", + }), + ), + ).toBe("[args]"); + }); + + it("ignores escaped numeric placeholders when inferring positional args", () => { + expect( + getCustomPromptArgumentHint( + makePrompt({ + name: "literal", + content: "Keep $$1 as text", + }), + ), + ).toBeUndefined(); + }); +}); + +describe("buildCustomPromptInsertText", () => { + it("builds prompt scaffolding and targets the first argument value", () => { + expect( + buildCustomPromptInsertText( + makePrompt({ + name: "review", + content: "Review $FILE with $LEVEL", + }), + ), + ).toEqual({ + text: '/prompts:review FILE="" LEVEL=""', + cursorOffset: '/prompts:review FILE="'.length, + }); + }); +}); + +describe("expandCustomPromptInvocation", () => { + it("expands named placeholders", () => { + const prompt = makePrompt({ + name: "review", + content: "Review $FILE with priority $LEVEL", + }); + expect( + expandCustomPromptInvocation('/prompts:review FILE="src/app.ts" LEVEL=high', [prompt]), + ).toEqual({ + expanded: "Review src/app.ts with priority high", + }); + }); + + it("expands positional placeholders and $ARGUMENTS", () => { + const prompt = makePrompt({ + name: "summarize", + content: "Summarize $1 using $2. Extra: $ARGUMENTS", + }); + expect(expandCustomPromptInvocation("/prompts:summarize repo quick detail", [prompt])).toEqual({ + expanded: "Summarize repo using quick. Extra: repo quick detail", + }); + }); + + it("preserves empty quoted positional arguments", () => { + const prompt = makePrompt({ + name: "summarize", + content: "Summarize $1 using $2 and $3", + }); + expect(expandCustomPromptInvocation('/prompts:summarize repo "" detail', [prompt])).toEqual({ + expanded: "Summarize repo using and detail", + }); + }); + + it("returns a parse error for malformed named arguments", () => { + const prompt = makePrompt({ + name: "review", + content: "Review $FILE", + }); + expect(expandCustomPromptInvocation("/prompts:review src/app.ts", [prompt])).toEqual({ + error: + "Could not parse /prompts:review: expected key=value but found 'src/app.ts'. Wrap values in double quotes if they contain spaces.", + }); + }); + + it("returns a missing arg error for incomplete named arguments", () => { + const prompt = makePrompt({ + name: "review", + content: "Review $FILE with $LEVEL", + }); + expect(expandCustomPromptInvocation('/prompts:review FILE="src/app.ts"', [prompt])).toEqual({ + error: + "Missing required args for /prompts:review: LEVEL. Provide as key=value (quote values with spaces).", + }); + }); + + it("ignores non-prompt slash commands", () => { + const prompt = makePrompt({ + name: "review", + content: "Review $FILE", + }); + expect(expandCustomPromptInvocation("/plan", [prompt])).toBeNull(); + }); +}); + +describe("findNextCustomPromptArgCursor", () => { + it("jumps across generated prompt argument placeholders", () => { + const text = '/prompts:review FILE="" LEVEL=""'; + const firstCursor = '/prompts:review FILE="'.length; + const secondCursor = '/prompts:review FILE="" LEVEL="'.length; + + expect(findNextCustomPromptArgCursor(text, firstCursor)).toBe(secondCursor); + expect(findNextCustomPromptArgCursor(text, secondCursor)).toBeNull(); + }); + + it("finds the next arg after values ending with escaped backslashes", () => { + const text = '/prompts:review FILE="value\\\\" LEVEL=""'; + const firstCursor = text.indexOf('FILE="') + 'FILE="'.length; + const secondCursor = text.indexOf('LEVEL="') + 'LEVEL="'.length; + + expect(findNextCustomPromptArgCursor(text, firstCursor)).toBe(secondCursor); + }); +}); diff --git a/packages/shared/src/codex.ts b/packages/shared/src/codex.ts new file mode 100644 index 0000000000..179cbf6e62 --- /dev/null +++ b/packages/shared/src/codex.ts @@ -0,0 +1,368 @@ +import type { CodexCustomPrompt } from "@t3tools/contracts"; + +const CUSTOM_PROMPTS_COMMAND_PREFIX = "prompts:"; +const CUSTOM_PROMPT_NAMED_ARG_REGEX = /\$[A-Z][A-Z0-9_]*/g; + +function normalizeQuotes(input: string): string { + return input.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'"); +} + +function promptArgumentNames(content: string): string[] { + const names: string[] = []; + const seen = new Set(); + const matches = content.matchAll(CUSTOM_PROMPT_NAMED_ARG_REGEX); + for (const match of matches) { + const index = match.index ?? 0; + if (index > 0 && content[index - 1] === "$") { + continue; + } + const name = match[0].slice(1); + if (name === "ARGUMENTS") { + continue; + } + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; +} + +function isEscapedDollarStart(content: string, index: number): boolean { + let consecutiveDollars = 0; + for (let cursor = index - 1; cursor >= 0 && content[cursor] === "$"; cursor -= 1) { + consecutiveDollars += 1; + } + return consecutiveDollars % 2 === 1; +} + +function promptHasNumericPlaceholders(content: string): boolean { + if (content.includes("$ARGUMENTS")) { + return true; + } + for (let index = 0; index + 1 < content.length; index += 1) { + if ( + content[index] === "$" && + /[1-9]/.test(content[index + 1] ?? "") && + !isEscapedDollarStart(content, index) + ) { + return true; + } + } + return false; +} + +function parseSlashName(text: string): { name: string; rest: string } | null { + if (!text.startsWith("/")) { + return null; + } + const stripped = text.slice(1); + let nameEnd = stripped.length; + for (let index = 0; index < stripped.length; index += 1) { + if (/\s/.test(stripped[index] ?? "")) { + nameEnd = index; + break; + } + } + const name = stripped.slice(0, nameEnd); + if (!name) { + return null; + } + return { + name, + rest: stripped.slice(nameEnd).trimStart(), + }; +} + +function splitShlex(input: string): string[] { + const tokens: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + let escaped = false; + let tokenStarted = false; + + for (const char of input) { + if (escaped) { + current += char; + escaped = false; + tokenStarted = true; + continue; + } + + if (!inSingle && char === "\\") { + escaped = true; + tokenStarted = true; + continue; + } + + if (!inDouble && char === "'") { + inSingle = !inSingle; + tokenStarted = true; + continue; + } + + if (!inSingle && char === '"') { + inDouble = !inDouble; + tokenStarted = true; + continue; + } + + if (!inSingle && !inDouble && /\s/.test(char)) { + if (tokenStarted) { + tokens.push(current); + current = ""; + tokenStarted = false; + } + continue; + } + + current += char; + tokenStarted = true; + } + + if (escaped) { + current += "\\"; + } + if (tokenStarted) { + tokens.push(current); + } + + return tokens; +} + +type PromptArgsError = + | { kind: "MissingAssignment"; token: string } + | { kind: "MissingKey"; token: string }; + +function formatPromptArgsError(command: string, error: PromptArgsError): string { + if (error.kind === "MissingAssignment") { + return `Could not parse ${command}: expected key=value but found '${error.token}'. Wrap values in double quotes if they contain spaces.`; + } + return `Could not parse ${command}: expected a name before '=' in '${error.token}'.`; +} + +function parsePromptInputs( + rest: string, +): { values: Record } | { error: PromptArgsError } { + const values: Record = {}; + if (!rest.trim()) { + return { values }; + } + const tokens = splitShlex(normalizeQuotes(rest)); + for (const token of tokens) { + const equalsIndex = token.indexOf("="); + if (equalsIndex <= 0) { + if (equalsIndex === 0) { + return { error: { kind: "MissingKey", token } }; + } + return { error: { kind: "MissingAssignment", token } }; + } + values[token.slice(0, equalsIndex)] = token.slice(equalsIndex + 1); + } + return { values }; +} + +function parsePositionalArgs(rest: string): string[] { + return splitShlex(normalizeQuotes(rest)); +} + +function expandNamedPlaceholders(content: string, inputs: Record): string { + return content.replace(CUSTOM_PROMPT_NAMED_ARG_REGEX, (match, offset) => { + if (offset > 0 && content[offset - 1] === "$") { + return match; + } + const key = match.slice(1); + return inputs[key] ?? match; + }); +} + +function expandNumericPlaceholders(content: string, args: string[]): string { + let output = ""; + let index = 0; + let joinedArguments: string | null = null; + + while (index < content.length) { + const nextDollar = content.indexOf("$", index); + if (nextDollar === -1) { + output += content.slice(index); + break; + } + output += content.slice(index, nextDollar); + const rest = content.slice(nextDollar); + const nextChar = rest[1]; + + if (nextChar === "$" && rest.length >= 2) { + output += "$$"; + index = nextDollar + 2; + continue; + } + + if (nextChar && /[1-9]/.test(nextChar)) { + const argIndex = Number(nextChar) - 1; + if (Number.isFinite(argIndex) && args[argIndex]) { + output += args[argIndex]; + } + index = nextDollar + 2; + continue; + } + + if (rest.length > 1 && rest.slice(1).startsWith("ARGUMENTS")) { + if (args.length > 0) { + if (joinedArguments === null) { + joinedArguments = args.join(" "); + } + output += joinedArguments; + } + index = nextDollar + 1 + "ARGUMENTS".length; + continue; + } + + output += "$"; + index = nextDollar + 1; + } + + return output; +} + +function isCustomPromptCommandLine(line: string): boolean { + return line.startsWith(`/${CUSTOM_PROMPTS_COMMAND_PREFIX}`); +} + +function isEscapedQuote(input: string, quoteIndex: number): boolean { + let backslashCount = 0; + for (let index = quoteIndex - 1; index >= 0 && input[index] === "\\"; index -= 1) { + backslashCount += 1; + } + return backslashCount % 2 === 1; +} + +function findCustomPromptArgRanges(line: string): Array<{ start: number; end: number }> { + if (!isCustomPromptCommandLine(line)) { + return []; + } + const normalized = normalizeQuotes(line); + const ranges: Array<{ start: number; end: number }> = []; + let index = 0; + while (index < normalized.length) { + const assignIndex = normalized.indexOf('="', index); + if (assignIndex === -1) { + break; + } + const valueStart = assignIndex + 2; + let end = valueStart; + let foundClosingQuote = false; + while (end < normalized.length) { + const char = normalized[end]; + if (char === '"' && !isEscapedQuote(normalized, end)) { + foundClosingQuote = true; + break; + } + end += 1; + } + if (!foundClosingQuote) { + break; + } + ranges.push({ start: valueStart, end }); + index = end + 1; + } + return ranges; +} + +export function getCustomPromptArgumentHint(prompt: CodexCustomPrompt): string | undefined { + const hint = prompt.argumentHint?.trim(); + if (hint) { + return hint; + } + const names = promptArgumentNames(prompt.content); + if (names.length > 0) { + return names.map((name) => `${name}=`).join(" "); + } + if (promptHasNumericPlaceholders(prompt.content)) { + return "[args]"; + } + return undefined; +} + +export function buildCustomPromptInsertText(prompt: CodexCustomPrompt): { + text: string; + cursorOffset?: number; +} { + const names = promptArgumentNames(prompt.content); + let text = `/${CUSTOM_PROMPTS_COMMAND_PREFIX}${prompt.name}`; + let cursorOffset: number | undefined; + for (const name of names) { + if (cursorOffset === undefined) { + cursorOffset = text.length + 1 + name.length + 2; + } + text += ` ${name}=""`; + } + return typeof cursorOffset === "number" ? { text, cursorOffset } : { text }; +} + +export function expandCustomPromptInvocation( + text: string, + prompts: ReadonlyArray, +): { expanded: string } | { error: string } | null { + const parsed = parseSlashName(text); + if (!parsed || !parsed.name.startsWith(CUSTOM_PROMPTS_COMMAND_PREFIX)) { + return null; + } + const promptName = parsed.name.slice(CUSTOM_PROMPTS_COMMAND_PREFIX.length); + if (!promptName) { + return null; + } + const prompt = prompts.find((entry) => entry.name === promptName); + if (!prompt) { + return null; + } + + const requiredNames = promptArgumentNames(prompt.content); + if (requiredNames.length > 0) { + const parsedInputs = parsePromptInputs(parsed.rest); + if ("error" in parsedInputs) { + return { + error: formatPromptArgsError(`/${parsed.name}`, parsedInputs.error), + }; + } + const missingNames = requiredNames.filter((name) => !(name in parsedInputs.values)); + if (missingNames.length > 0) { + return { + error: `Missing required args for /${parsed.name}: ${missingNames.join(", ")}. Provide as key=value (quote values with spaces).`, + }; + } + return { + expanded: expandNamedPlaceholders(prompt.content, parsedInputs.values), + }; + } + + return { + expanded: expandNumericPlaceholders(prompt.content, parsePositionalArgs(parsed.rest)), + }; +} + +export function findNextCustomPromptArgCursor(text: string, cursor: number): number | null { + const lineEnd = text.indexOf("\n"); + const safeLineEnd = lineEnd === -1 ? text.length : lineEnd; + if (cursor > safeLineEnd) { + return null; + } + const line = text.slice(0, safeLineEnd); + const ranges = findCustomPromptArgRanges(line); + if (ranges.length === 0) { + return null; + } + for (let index = 0; index < ranges.length; index += 1) { + const range = ranges[index]; + if (!range) { + continue; + } + if (cursor >= range.start && cursor <= range.end) { + return ranges[index + 1]?.start ?? null; + } + if (cursor < range.start) { + return range.start; + } + } + return null; +}