diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index ab3b7a569d..6039d6d240 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -546,6 +546,33 @@ describe("sendTurn", () => { }); }); + it("sends structured skill items to turn/start", async () => { + const { manager, context, sendRequest } = createSendTurnHarness(); + + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "Use the review skill", + skills: [{ name: "review", path: "/Users/example/.codex/skills/review" }], + }); + + expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { + threadId: "thread_1", + input: [ + { + type: "text", + text: "Use the review skill", + text_elements: [], + }, + { + type: "skill", + name: "review", + path: "/Users/example/.codex/skills/review", + }, + ], + model: "gpt-5.3-codex", + }); + }); + it("passes Codex plan mode as a collaboration preset on turn/start", async () => { const { manager, context, sendRequest } = createSendTurnHarness(); @@ -644,7 +671,7 @@ describe("sendTurn", () => { manager.sendTurn({ threadId: asThreadId("thread_1"), }), - ).rejects.toThrow("Turn input must include text or attachments."); + ).rejects.toThrow("Turn input must include text, attachments, or skills."); }); }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 230ba8e364..d47d38b5b3 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -8,6 +8,7 @@ import { EventId, ProviderItemId, ProviderRequestKind, + type ProviderSkillReference, type ProviderUserInputAnswers, ThreadId, TurnId, @@ -109,6 +110,7 @@ export interface CodexAppServerSendTurnInput { readonly threadId: ThreadId; readonly input?: string; readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; + readonly skills?: ReadonlyArray; readonly model?: string; readonly serviceTier?: string | null; readonly effort?: string; @@ -658,7 +660,9 @@ export class CodexAppServerManager extends EventEmitter = []; if (input.input) { turnInput.push({ @@ -675,8 +679,15 @@ export class CodexAppServerManager extends EventEmitter; model?: string; serviceTier?: string | null; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a1a69f0efa..159d2e7cc8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -4,6 +4,7 @@ import { EventId, type ModelSelection, type OrchestrationEvent, + type ProviderSkillReference, ProviderKind, type OrchestrationSession, ThreadId, @@ -362,6 +363,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; + readonly skills?: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; @@ -380,6 +382,7 @@ const make = Effect.gen(function* () { } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; + const normalizedSkills = input.skills ?? []; const activeSession = yield* providerService .listSessions() .pipe( @@ -405,6 +408,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), + ...(normalizedSkills.length > 0 ? { skills: normalizedSkills } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); @@ -569,6 +573,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.skills !== undefined ? { skills: event.payload.skills } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index df10007db7..54c1dac7fa 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -451,6 +451,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), + ...(command.skills !== undefined ? { skills: command.skills } : {}), ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, diff --git a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts index 3b387fdcfd..9d791ca475 100644 --- a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts +++ b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts @@ -4,6 +4,52 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Older local databases may have already consumed migration id 20 for a + // different custom migration, which means the new auth base tables were never + // created. Make this migration self-healing so later ALTER steps do not fail. + yield* sql` + CREATE TABLE IF NOT EXISTS auth_pairing_links ( + id TEXT PRIMARY KEY, + credential TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + role TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT, + revoked_at TEXT, + label TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active + ON auth_pairing_links(revoked_at, consumed_at, expires_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + subject TEXT NOT NULL, + role TEXT NOT NULL, + method TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + client_label TEXT, + client_ip_address TEXT, + client_user_agent TEXT, + client_device_type TEXT NOT NULL DEFAULT 'unknown', + client_os TEXT, + client_browser TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_sessions_active + ON auth_sessions(revoked_at, expires_at, issued_at) + `; + const pairingLinkColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(auth_pairing_links) `; diff --git a/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts index e806a073a5..f9ec44a76e 100644 --- a/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts +++ b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts @@ -4,6 +4,32 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Same recovery path as migration 021: ensure the auth sessions table exists + // even when migration 020 was skipped on an existing local database. + yield* sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + subject TEXT NOT NULL, + role TEXT NOT NULL, + method TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + client_label TEXT, + client_ip_address TEXT, + client_user_agent TEXT, + client_device_type TEXT NOT NULL DEFAULT 'unknown', + client_os TEXT, + client_browser TEXT, + last_connected_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_sessions_active + ON auth_sessions(revoked_at, expires_at, issued_at) + `; + const sessionColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(auth_sessions) `; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3278aa2105..01108ea73f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -282,6 +282,28 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }); }), ); + + it.effect("forwards structured codex skills when present", () => + Effect.gen(function* () { + sessionErrorManager.sendTurnImpl.mockClear(); + const adapter = yield* CodexAdapter; + + yield* Effect.ignore( + adapter.sendTurn({ + threadId: asThreadId("sess-missing"), + input: "hello", + skills: [{ name: "review", path: "/Users/example/.codex/skills/review" }], + attachments: [], + }), + ); + + assert.deepStrictEqual(sessionErrorManager.sendTurnImpl.mock.calls[0]?.[0], { + threadId: asThreadId("sess-missing"), + input: "hello", + skills: [{ name: "review", path: "/Users/example/.codex/skills/review" }], + }); + }), + ); }); const lifecycleManager = new FakeCodexManager(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index cae34eaaf0..039e059276 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1478,6 +1478,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const managerInput = { threadId: input.threadId, ...(input.input !== undefined ? { input: input.input } : {}), + ...(input.skills !== undefined ? { skills: input.skills } : {}), ...(input.modelSelection?.provider === "codex" ? { model: input.modelSelection.model } : {}), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 85fe9fbc32..dc5558526b 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -386,11 +386,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const input = { ...parsed, attachments: parsed.attachments ?? [], + skills: parsed.skills ?? [], }; - if (!input.input && input.attachments.length === 0) { + if (!input.input && input.attachments.length === 0 && input.skills.length === 0) { return yield* toValidationError( "ProviderService.sendTurn", - "Either input text or at least one attachment is required", + "Either input text, at least one attachment, or at least one skill is required", ); } yield* Effect.annotateCurrentSpan({ @@ -398,6 +399,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.thread_id": input.threadId, "provider.interaction_mode": input.interactionMode, "provider.attachment_count": input.attachments.length, + "provider.skill_count": input.skills.length, }); let metricProvider = "unknown"; let metricModel = input.modelSelection?.model; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8d807afba3..408b91fadc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1962,6 +1962,63 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.listProviderCommands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-provider-commands-", + }); + yield* fs.makeDirectory(path.join(workspaceDir, ".codex", "skills", "review"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workspaceDir, ".codex", "skills", "review", "SKILL.md"), + "---\ndescription: Review staged changes\n---\n", + ); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsListProviderCommands]({ + cwd: workspaceDir, + provider: "codex", + }), + ), + ); + + assert.equal(response.provider, "codex"); + assert.isTrue(response.skills.some((entry) => entry.name === "review")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc projects.listProviderCommands errors for invalid workspaces", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsListProviderCommands]({ + cwd: "/definitely/not/a/real/workspace/path", + provider: "codex", + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ProviderCommandsListError"); + assertInclude( + result.failure.message, + "Workspace root does not exist: /definitely/not/a/real/workspace/path", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/workspace/providerCommandsDiscovery.test.ts b/apps/server/src/workspace/providerCommandsDiscovery.test.ts new file mode 100644 index 0000000000..f175840e72 --- /dev/null +++ b/apps/server/src/workspace/providerCommandsDiscovery.test.ts @@ -0,0 +1,94 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { listProviderCommands } from "./providerCommandsDiscovery"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("listProviderCommands", () => { + it("discovers codex project skills and commands", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-provider-commands-")); + tempDirs.push(cwd); + await mkdir(path.join(cwd, ".codex", "commands"), { recursive: true }); + await mkdir(path.join(cwd, ".codex", "skills", "review"), { recursive: true }); + await writeFile(path.join(cwd, ".codex", "commands", "ship-it.md"), "Ship it safely"); + await writeFile( + path.join(cwd, ".codex", "skills", "review", "SKILL.md"), + "---\ndescription: Review staged changes\n---\n", + ); + + const result = await Effect.runPromise(listProviderCommands({ provider: "codex", cwd })); + + expect(result.commands).toEqual([ + expect.objectContaining({ + name: "ship-it", + source: "project", + }), + ]); + expect(result.skills).toContainEqual( + expect.objectContaining({ + name: "review", + source: "project", + path: path.join(cwd, ".codex", "skills", "review"), + }), + ); + }); + + it("includes Claude built-in commands", async () => { + const result = await Effect.runPromise(listProviderCommands({ provider: "claudeAgent" })); + + expect(result.commands.some((entry) => entry.name === "simplify")).toBe(true); + expect(result.provider).toBe("claudeAgent"); + }); + + it("discovers hidden Codex system skills under .system", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-provider-commands-system-")); + tempDirs.push(cwd); + await mkdir(path.join(cwd, ".codex", "skills", ".system", "openai-docs"), { + recursive: true, + }); + await writeFile( + path.join(cwd, ".codex", "skills", ".system", "openai-docs", "SKILL.md"), + "---\ndescription: Use official OpenAI docs\n---\n", + ); + + const result = await Effect.runPromise(listProviderCommands({ provider: "codex", cwd })); + + expect(result.skills).toContainEqual( + expect.objectContaining({ + name: "openai-docs", + source: "project", + path: path.join(cwd, ".codex", "skills", ".system", "openai-docs"), + }), + ); + }); + + it("discovers Codex skills from ~/.agents/skills-compatible roots", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-provider-commands-agents-")); + tempDirs.push(cwd); + await mkdir(path.join(cwd, ".agents", "skills", "planner"), { + recursive: true, + }); + await writeFile( + path.join(cwd, ".agents", "skills", "planner", "SKILL.md"), + "---\ndescription: Plan implementation work\n---\n", + ); + + const result = await Effect.runPromise(listProviderCommands({ provider: "codex", cwd })); + + expect(result.skills).toContainEqual( + expect.objectContaining({ + name: "planner", + source: "project", + path: path.join(cwd, ".agents", "skills", "planner"), + }), + ); + }); +}); diff --git a/apps/server/src/workspace/providerCommandsDiscovery.ts b/apps/server/src/workspace/providerCommandsDiscovery.ts new file mode 100644 index 0000000000..b07ed5202c --- /dev/null +++ b/apps/server/src/workspace/providerCommandsDiscovery.ts @@ -0,0 +1,300 @@ +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { + type ProviderCommandEntry, + type ProviderCommandsListInput, + type ProviderCommandsListResult, + type ProviderCommandSource, + type ProviderKind, + ProviderCommandsListError, +} from "@t3tools/contracts"; +import { Effect } from "effect"; + +interface ProviderLayout { + readonly directoryNames: ReadonlyArray; + readonly commandSubpaths: ReadonlyArray; + readonly skillSubpaths: ReadonlyArray; + readonly builtinCommands?: ReadonlyArray; + readonly allowedHiddenSkillDirectories?: ReadonlySet; + readonly extraUserSkillRoots?: ReadonlyArray; + readonly extraProjectSkillRoots?: ReadonlyArray; +} + +const CLAUDE_BUILTIN_COMMANDS: ReadonlyArray = [ + { + name: "batch", + source: "builtin", + description: "Plan and execute a larger change in parallel.", + }, + { name: "claude-api", source: "builtin", description: "Help with Claude API or SDK work." }, + { + name: "claude-in-chrome", + source: "builtin", + description: "Automate Chrome to inspect and interact with pages.", + }, + { + name: "debug", + source: "builtin", + description: "Enable debug logging for the current session.", + }, + { name: "loop", source: "builtin", description: "Run a prompt or slash command on an interval." }, + { name: "schedule", source: "builtin", description: "Create or run scheduled remote agents." }, + { name: "simplify", source: "builtin", description: "Review changed code and clean it up." }, +] as const; + +const PROVIDER_LAYOUTS: Record = { + codex: { + directoryNames: [".codex"], + commandSubpaths: ["commands", "prompts"], + skillSubpaths: ["skills"], + allowedHiddenSkillDirectories: new Set([".system"]), + extraUserSkillRoots: [".agents/skills", ".agent/skills"], + extraProjectSkillRoots: [".agents/skills", ".agent/skills"], + }, + claudeAgent: { + directoryNames: [".claude"], + commandSubpaths: ["commands"], + skillSubpaths: ["skills"], + builtinCommands: CLAUDE_BUILTIN_COMMANDS, + }, +}; + +const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*/; +const DESCRIPTION_PATTERN = /^description\s*:\s*(.+)$/im; + +function stripExtension(name: string): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(0, dot) : name; +} + +function normalizeDescription(value: string): string { + const trimmed = value.trim().replace(/^["']|["']$/g, ""); + return trimmed.length <= 480 ? trimmed : `${trimmed.slice(0, 477)}...`; +} + +function extractDescription(content: string): string | undefined { + const frontmatter = FRONTMATTER_PATTERN.exec(content); + if (frontmatter?.[1]) { + const match = DESCRIPTION_PATTERN.exec(frontmatter[1]); + if (match?.[1]) { + return normalizeDescription(match[1]); + } + } + const body = frontmatter ? content.slice(frontmatter[0].length) : content; + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + return normalizeDescription(trimmed); + } + return undefined; +} + +async function readDirEntries(dir: string) { + try { + return await fs.readdir(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +async function readTextFile(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + return null; + } +} + +async function collectCommandsFromDir( + dir: string, + source: ProviderCommandSource, + prefix = "", +): Promise { + const entries = await readDirEntries(dir); + const discovered: ProviderCommandEntry[] = []; + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nextPrefix = prefix ? `${prefix}/${entry.name}` : entry.name; + discovered.push(...(await collectCommandsFromDir(entryPath, source, nextPrefix))); + continue; + } + if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) { + continue; + } + const baseName = stripExtension(entry.name); + if (!baseName) { + continue; + } + const commandName = prefix ? `${prefix}/${baseName}` : baseName; + const content = (await readTextFile(entryPath)) ?? ""; + discovered.push({ + name: commandName, + source, + path: entryPath, + ...(extractDescription(content) ? { description: extractDescription(content) } : {}), + }); + } + return discovered; +} + +async function collectSkillsFromDir( + dir: string, + source: ProviderCommandSource, + options?: { + readonly allowedHiddenDirectories?: ReadonlySet; + }, +): Promise { + const entries = await readDirEntries(dir); + const discovered: ProviderCommandEntry[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const isHiddenDirectory = entry.name.startsWith("."); + if (isHiddenDirectory && !options?.allowedHiddenDirectories?.has(entry.name)) { + continue; + } + const skillDir = path.join(dir, entry.name); + const skillFile = + (await readTextFile(path.join(skillDir, "SKILL.md"))) ?? + (await readTextFile(path.join(skillDir, "skill.md"))); + if (skillFile !== null) { + discovered.push({ + name: entry.name, + source, + path: skillDir, + ...(extractDescription(skillFile) ? { description: extractDescription(skillFile) } : {}), + }); + } + discovered.push( + ...(await collectSkillsFromDir( + skillDir, + source, + options?.allowedHiddenDirectories + ? { allowedHiddenDirectories: options.allowedHiddenDirectories } + : undefined, + )), + ); + } + return discovered; +} + +function dedupeByName( + entries: ReadonlyArray, +): ReadonlyArray { + const sourceRank: Record = { + project: 0, + user: 1, + builtin: 2, + }; + const byName = new Map(); + for (const entry of entries) { + const existing = byName.get(entry.name); + if (!existing || sourceRank[entry.source] < sourceRank[existing.source]) { + byName.set(entry.name, entry); + } + } + return [...byName.values()].toSorted((left, right) => left.name.localeCompare(right.name)); +} + +async function discoverInternal( + input: ProviderCommandsListInput, +): Promise { + const layout = PROVIDER_LAYOUTS[input.provider]; + const home = homedir(); + const roots: Array<{ root: string; source: ProviderCommandSource }> = layout.directoryNames.map( + (directoryName) => ({ + root: path.join(home, directoryName), + source: "user" as const, + }), + ); + if (input.cwd) { + roots.push( + ...layout.directoryNames.map((directoryName) => ({ + root: path.join(input.cwd!, directoryName), + source: "project" as const, + })), + ); + } + + const discoveredCommands = [...(layout.builtinCommands ?? [])]; + const discoveredSkills: ProviderCommandEntry[] = []; + + await Promise.all( + roots.map(async ({ root, source }) => { + const [commands, skills] = await Promise.all([ + Promise.all( + layout.commandSubpaths.map((subpath) => + collectCommandsFromDir(path.join(root, subpath), source), + ), + ), + Promise.all( + layout.skillSubpaths.map((subpath) => + collectSkillsFromDir( + path.join(root, subpath), + source, + layout.allowedHiddenSkillDirectories + ? { allowedHiddenDirectories: layout.allowedHiddenSkillDirectories } + : undefined, + ), + ), + ), + ]); + discoveredCommands.push(...commands.flat()); + discoveredSkills.push(...skills.flat()); + }), + ); + + const extraSkillRoots: Array<{ root: string; source: ProviderCommandSource }> = [ + ...(layout.extraUserSkillRoots ?? []).map((relativeRoot) => ({ + root: path.join(home, relativeRoot), + source: "user" as const, + })), + ...((input.cwd ? (layout.extraProjectSkillRoots ?? []) : []).map((relativeRoot) => ({ + root: path.join(input.cwd!, relativeRoot), + source: "project" as const, + })) ?? []), + ]; + + await Promise.all( + extraSkillRoots.map(async ({ root, source }) => { + discoveredSkills.push( + ...(await collectSkillsFromDir( + root, + source, + layout.allowedHiddenSkillDirectories + ? { allowedHiddenDirectories: layout.allowedHiddenSkillDirectories } + : undefined, + )), + ); + }), + ); + + return { + provider: input.provider, + commands: [...dedupeByName(discoveredCommands)], + skills: [...dedupeByName(discoveredSkills)], + }; +} + +export function listProviderCommands(input: ProviderCommandsListInput) { + return Effect.tryPromise({ + try: () => discoverInternal(input), + catch: (cause) => + new ProviderCommandsListError({ + message: + cause instanceof Error + ? cause.message + : "Failed to discover provider commands and skills.", + cause, + }), + }); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c7b1fc4804..11c2340946 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,6 +13,7 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + ProviderCommandsListError, ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, @@ -47,7 +48,8 @@ import { ServerSettingsService } from "./serverSettings"; import { TerminalManager } from "./terminal/Services/Manager"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; +import { WorkspacePathOutsideRootError, WorkspacePaths } from "./workspace/Services/WorkspacePaths"; +import { listProviderCommands } from "./workspace/providerCommandsDiscovery"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; @@ -121,6 +123,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const startup = yield* ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspacePaths = yield* WorkspacePaths; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; @@ -668,6 +671,28 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsListProviderCommands]: (input) => + observeRpcEffect( + WS_METHODS.projectsListProviderCommands, + Effect.gen(function* () { + const normalizedCwd = input.cwd + ? yield* workspacePaths.normalizeWorkspaceRoot(input.cwd) + : undefined; + return yield* listProviderCommands({ + provider: input.provider, + ...(normalizedCwd ? { cwd: normalizedCwd } : {}), + }); + }).pipe( + Effect.mapError( + (cause) => + new ProviderCommandsListError({ + message: `Failed to discover provider commands: ${cause.message}`, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( WS_METHODS.projectsWriteFile, diff --git a/apps/web/src/codexSkillSelections.test.ts b/apps/web/src/codexSkillSelections.test.ts new file mode 100644 index 0000000000..0e1c29c3e0 --- /dev/null +++ b/apps/web/src/codexSkillSelections.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + deriveComposerSkillSelections, + toCodexSkillReferencesForSend, +} from "./codexSkillSelections"; + +describe("deriveComposerSkillSelections", () => { + it("maps recognized prompt tokens to structured skill selections", () => { + expect( + deriveComposerSkillSelections({ + prompt: "Use $review please", + availableSkills: [{ name: "review", path: "/skills/review" }], + }), + ).toEqual([ + { + name: "review", + path: "/skills/review", + rangeStart: "Use ".length, + rangeEnd: "Use $review".length, + }, + ]); + }); + + it("ignores tokens that are not part of the discovered codex catalog", () => { + expect( + deriveComposerSkillSelections({ + prompt: "echo $HOME and $review please", + availableSkills: [{ name: "review", path: "/skills/review" }], + }), + ).toEqual([ + { + name: "review", + path: "/skills/review", + rangeStart: "echo $HOME and ".length, + rangeEnd: "echo $HOME and $review".length, + }, + ]); + }); +}); + +describe("toCodexSkillReferencesForSend", () => { + it("deduplicates repeated mentions of the same skill before dispatch", () => { + expect( + toCodexSkillReferencesForSend([ + { name: "review", path: "/skills/review", rangeStart: 0, rangeEnd: 7 }, + { name: "review", path: "/skills/review", rangeStart: 10, rangeEnd: 17 }, + ]), + ).toEqual([{ name: "review", path: "/skills/review" }]); + }); +}); diff --git a/apps/web/src/codexSkillSelections.ts b/apps/web/src/codexSkillSelections.ts new file mode 100644 index 0000000000..57cec0a36a --- /dev/null +++ b/apps/web/src/codexSkillSelections.ts @@ -0,0 +1,70 @@ +import type { ProviderCommandEntry } from "@t3tools/contracts"; + +export interface ComposerSkillSelection { + name: string; + path: string; + rangeStart: number; + rangeEnd: number; +} + +const CODEX_SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9_:-]*)(?=\s)/g; + +function extractCodexSkillInvocations(prompt: string) { + const invocations: Array<{ name: string; rangeStart: number; rangeEnd: number }> = []; + for (const match of prompt.matchAll(CODEX_SKILL_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const name = match[2] ?? ""; + const matchIndex = match.index ?? 0; + const rangeStart = matchIndex + prefix.length; + const rangeEnd = rangeStart + fullMatch.length - prefix.length; + invocations.push({ name, rangeStart, rangeEnd }); + } + return invocations; +} + +function buildSkillPathByName( + availableSkills: ReadonlyArray>, +): Map { + const pathByName = new Map(); + for (const skill of availableSkills) { + if (typeof skill.path !== "string" || skill.path.length === 0) { + continue; + } + pathByName.set(skill.name, skill.path); + } + return pathByName; +} + +export function deriveComposerSkillSelections(input: { + prompt: string; + availableSkills: ReadonlyArray>; +}): ComposerSkillSelection[] { + const skillPathByName = buildSkillPathByName(input.availableSkills); + return extractCodexSkillInvocations(input.prompt).flatMap((invocation) => { + const path = skillPathByName.get(invocation.name); + return path + ? [ + { + name: invocation.name, + path, + rangeStart: invocation.rangeStart, + rangeEnd: invocation.rangeEnd, + } satisfies ComposerSkillSelection, + ] + : []; + }); +} + +export function toCodexSkillReferencesForSend( + selections: readonly ComposerSkillSelection[], +): Array<{ name: string; path: string }> { + const uniqueSelections = new Map(); + for (const selection of selections) { + uniqueSelections.set(`${selection.name}:${selection.path}`, { + name: selection.name, + path: selection.path, + }); + } + return [...uniqueSelections.values()]; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d4861df1f2..3290920bb8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2451,6 +2451,7 @@ export default function ChatView(props: ChatViewProps) { images: composerImages, fileReferences: composerFileReferences, terminalContexts: composerTerminalContexts, + selectedSkills: composerSelectedSkills, isResolvingFileReferences, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, @@ -2700,6 +2701,7 @@ export default function ChatView(props: ChatViewProps) { attachments: turnAttachments, }, modelSelection: ctxSelectedModelSelection, + ...(composerSelectedSkills.length > 0 ? { skills: composerSelectedSkills } : {}), titleSeed: title, runtimeMode, interactionMode, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index e7a7732213..1635d84ed6 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -30,13 +30,10 @@ import { DecoratorNode, type ElementNode, type LexicalNode, - type SerializedLexicalNode, - TextNode, - type EditorConfig, - type EditorState, type NodeKey, - type SerializedTextNode, + type SerializedLexicalNode, type Spread, + type EditorState, } from "lexical"; import { createContext, @@ -68,13 +65,14 @@ import { type TerminalContextDraft, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; -import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; -import { - COMPOSER_INLINE_CHIP_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, -} from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; +import { + $createComposerCustomTokenNode, + $createComposerMentionNode, + ComposerCustomTokenNode, + ComposerMentionNode, + isComposerInlineTextNode, +} from "./composerInlineTextNodes"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; const SURROUND_SYMBOLS: [string, string][] = [ @@ -93,15 +91,6 @@ const SURROUND_SYMBOLS: [string, string][] = [ const SURROUND_SYMBOLS_MAP = new Map(SURROUND_SYMBOLS); const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; -type SerializedComposerMentionNode = Spread< - { - path: string; - type: "composer-mention"; - version: 1; - }, - SerializedTextNode ->; - type SerializedComposerTerminalContextNode = Spread< { context: TerminalContextDraft; @@ -117,78 +106,6 @@ const ComposerTerminalContextActionsContext = createContext<{ onRemoveTerminalContext: () => {}, }); -class ComposerMentionNode extends TextNode { - __path: string; - - static override getType(): string { - return "composer-mention"; - } - - static override clone(node: ComposerMentionNode): ComposerMentionNode { - return new ComposerMentionNode(node.__path, node.__key); - } - - static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path); - } - - constructor(path: string, key?: NodeKey) { - const normalizedPath = path.startsWith("@") ? path.slice(1) : path; - super(`@${normalizedPath}`, key); - this.__path = normalizedPath; - } - - override exportJSON(): SerializedComposerMentionNode { - return { - ...super.exportJSON(), - path: this.__path, - type: "composer-mention", - version: 1, - }; - } - - override createDOM(_config: EditorConfig): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - dom.setAttribute("spellcheck", "false"); - renderMentionChipDom(dom, this.__path); - return dom; - } - - override updateDOM( - prevNode: ComposerMentionNode, - dom: HTMLElement, - _config: EditorConfig, - ): boolean { - dom.contentEditable = "false"; - if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { - renderMentionChipDom(dom, this.__path); - } - return false; - } - - override canInsertTextBefore(): false { - return false; - } - - override canInsertTextAfter(): false { - return false; - } - - override isTextEntity(): true { - return true; - } - - override isToken(): true { - return true; - } -} - -function $createComposerMentionNode(path: string): ComposerMentionNode { - return $applyNodeReplacement(new ComposerMentionNode(path)); -} - function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } @@ -253,36 +170,13 @@ function $createComposerTerminalContextNode( return $applyNodeReplacement(new ComposerTerminalContextNode(context)); } -type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; +type ComposerInlineTokenNode = + | ComposerMentionNode + | ComposerCustomTokenNode + | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { - return ( - candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode - ); -} - -function resolvedThemeFromDocument(): "light" | "dark" { - return document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - -function renderMentionChipDom(container: HTMLElement, pathValue: string): void { - container.textContent = ""; - container.style.setProperty("user-select", "none"); - container.style.setProperty("-webkit-user-select", "none"); - - const theme = resolvedThemeFromDocument(); - const icon = document.createElement("img"); - icon.alt = ""; - icon.ariaHidden = "true"; - icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; - icon.loading = "lazy"; - icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); - - const label = document.createElement("span"); - label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; - label.textContent = basenameOfPath(pathValue); - - container.append(icon, label); + return isComposerInlineTextNode(candidate) || candidate instanceof ComposerTerminalContextNode; } function terminalContextSignature(contexts: ReadonlyArray): string { @@ -302,6 +196,10 @@ function terminalContextSignature(contexts: ReadonlyArray) .join("\u001e"); } +function customTokenTextsSignature(customTokenTexts: ReadonlyArray): string { + return customTokenTexts.join("\u001e"); +} + function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); @@ -410,7 +308,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerCustomTokenNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); @@ -457,7 +355,7 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerCustomTokenNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); @@ -491,6 +389,9 @@ function findSelectionPointAtOffset( if (node instanceof ComposerMentionNode) { return findSelectionPointForInlineToken(node, remainingRef); } + if (node instanceof ComposerCustomTokenNode) { + return findSelectionPointForInlineToken(node, remainingRef); + } if (node instanceof ComposerTerminalContextNode) { return findSelectionPointForInlineToken(node, remainingRef); } @@ -700,18 +601,25 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, + customTokenTexts: ReadonlyArray = [], ): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); - const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); + const segments = splitPromptIntoComposerSegments(prompt, terminalContexts, { + customTokenTexts, + }); for (const segment of segments) { if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path)); continue; } + if (segment.type === "custom-token") { + paragraph.append($createComposerCustomTokenNode(segment.tokenText)); + continue; + } if (segment.type === "terminal-context") { if (segment.context) { paragraph.append($createComposerTerminalContextNode(segment.context)); @@ -752,6 +660,7 @@ interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; + customTokenTexts?: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; @@ -831,7 +740,7 @@ function ComposerCommandKeyPlugin(props: { return null; } -function ComposerInlineTokenArrowPlugin() { +function ComposerInlineTokenArrowPlugin(props: { customTokenTexts: ReadonlyArray }) { const [editor] = useLexicalComposerContext(); useEffect(() => { @@ -845,7 +754,11 @@ function ComposerInlineTokenArrowPlugin() { const currentOffset = $readSelectionOffsetFromEditorState(0); if (currentOffset <= 0) return; const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { + if ( + !isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left", { + customTokenTexts: props.customTokenTexts, + }) + ) { return; } nextOffset = currentOffset - 1; @@ -872,7 +785,11 @@ function ComposerInlineTokenArrowPlugin() { const composerLength = $getComposerRootLength(); if (currentOffset >= composerLength) return; const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { + if ( + !isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right", { + customTokenTexts: props.customTokenTexts, + }) + ) { return; } nextOffset = currentOffset + 1; @@ -892,7 +809,7 @@ function ComposerInlineTokenArrowPlugin() { unregisterLeft(); unregisterRight(); }; - }, [editor]); + }, [editor, props.customTokenTexts]); return null; } @@ -994,9 +911,11 @@ function ComposerInlineTokenBackspacePlugin() { function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; + customTokenTexts: ReadonlyArray; }) { const [editor] = useLexicalComposerContext(); const terminalContextsRef = useRef(props.terminalContexts); + const customTokenTextsRef = useRef(props.customTokenTexts); const pendingSurroundSelectionRef = useRef<{ value: string; expandedStart: number; @@ -1012,6 +931,10 @@ function ComposerSurroundSelectionPlugin(props: { terminalContextsRef.current = props.terminalContexts; }, [props.terminalContexts]); + useEffect(() => { + customTokenTextsRef.current = props.customTokenTexts; + }, [props.customTokenTexts]); + const applySurroundInsertion = useCallback( (inputData: string): boolean => { const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); @@ -1038,7 +961,11 @@ function ComposerSurroundSelectionPlugin(props: { return null; } const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + if ( + selectionTouchesMentionBoundary(value, range.start, range.end, { + customTokenTexts: customTokenTextsRef.current, + }) + ) { return null; } return { @@ -1057,10 +984,15 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, terminalContextsRef.current); + $setComposerEditorPrompt( + nextValue, + terminalContextsRef.current, + customTokenTextsRef.current, + ); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, + { customTokenTexts: customTokenTextsRef.current }, ); $setSelectionRangeAtComposerOffsets( selectionStart + inputData.length, @@ -1109,7 +1041,11 @@ function ComposerSurroundSelectionPlugin(props: { return; } const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + if ( + selectionTouchesMentionBoundary(value, range.start, range.end, { + customTokenTexts: customTokenTextsRef.current, + }) + ) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; @@ -1193,6 +1129,7 @@ function ComposerSurroundSelectionPlugin(props: { const replacementStart = collapseExpandedComposerCursor( currentValue, pendingDeadKeySelection.expandedStart, + { customTokenTexts: customTokenTextsRef.current }, ); $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); const replacementSelection = $getSelection(); @@ -1259,6 +1196,7 @@ function ComposerPromptEditorInner({ value, cursor, terminalContexts, + customTokenTexts = [], disabled, placeholder, className, @@ -1271,10 +1209,15 @@ function ComposerPromptEditorInner({ }: ComposerPromptEditorInnerProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); - const initialCursor = clampCollapsedComposerCursor(value, cursor); + const customTokenTextsRef = useRef(customTokenTexts); + const initialCursor = clampCollapsedComposerCursor(value, cursor, { customTokenTexts }); const terminalContextsSignature = terminalContextSignature(terminalContexts); + const currentCustomTokenTextsSignature = customTokenTextsSignature(customTokenTexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); - const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); + const customTokenTextsSignatureRef = useRef(currentCustomTokenTextsSignature); + const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor, { + customTokenTexts, + }); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -1295,18 +1238,25 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); + useEffect(() => { + customTokenTextsRef.current = customTokenTexts; + }, [customTokenTexts]); + useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); useLayoutEffect(() => { - const normalizedCursor = clampCollapsedComposerCursor(value, cursor); + const normalizedCursor = clampCollapsedComposerCursor(value, cursor, { customTokenTexts }); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; + const customTokensChanged = + customTokenTextsSignatureRef.current !== currentCustomTokenTextsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && - !contextsChanged + !contextsChanged && + !customTokensChanged ) { return; } @@ -1314,26 +1264,39 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value, cursor: normalizedCursor, - expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor, { + customTokenTexts, + }), selectionStart: normalizedCursor, selectionEnd: normalizedCursor, - expandedSelectionStart: expandCollapsedComposerCursor(value, normalizedCursor), - expandedSelectionEnd: expandCollapsedComposerCursor(value, normalizedCursor), + expandedSelectionStart: expandCollapsedComposerCursor(value, normalizedCursor, { + customTokenTexts, + }), + expandedSelectionEnd: expandCollapsedComposerCursor(value, normalizedCursor, { + customTokenTexts, + }), terminalContextIds: terminalContexts.map((context) => context.id), }; terminalContextsSignatureRef.current = terminalContextsSignature; + customTokenTextsSignatureRef.current = currentCustomTokenTextsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !contextsChanged && !isFocused) { + if ( + previousSnapshot.value === value && + !contextsChanged && + !customTokensChanged && + !isFocused + ) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { - const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; + const shouldRewriteEditorState = + previousSnapshot.value !== value || contextsChanged || customTokensChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts); + $setComposerEditorPrompt(value, terminalContexts, customTokenTexts); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1342,13 +1305,23 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, terminalContexts, terminalContextsSignature, value]); + }, [ + currentCustomTokenTextsSignature, + customTokenTexts, + cursor, + editor, + terminalContexts, + terminalContextsSignature, + value, + ]); const focusAt = useCallback( (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor, { + customTokenTexts: customTokenTextsRef.current, + }); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); @@ -1356,16 +1329,20 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor, { + customTokenTexts: customTokenTextsRef.current, + }), selectionStart: boundedCursor, selectionEnd: boundedCursor, expandedSelectionStart: expandCollapsedComposerCursor( snapshotRef.current.value, boundedCursor, + { customTokenTexts: customTokenTextsRef.current }, ), expandedSelectionEnd: expandCollapsedComposerCursor( snapshotRef.current.value, boundedCursor, + { customTokenTexts: customTokenTextsRef.current }, ), terminalContextIds: snapshotRef.current.terminalContextIds, }; @@ -1393,10 +1370,13 @@ function ComposerPromptEditorInner({ let snapshot = snapshotRef.current; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); + const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor, { + customTokenTexts: customTokenTextsRef.current, + }); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), + { customTokenTexts: customTokenTextsRef.current }, ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, @@ -1407,8 +1387,12 @@ function ComposerPromptEditorInner({ $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const selectionOffsets = $readSelectionOffsetsFromEditorState({ - start: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionStart), - end: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionEnd), + start: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionStart, { + customTokenTexts: customTokenTextsRef.current, + }), + end: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionEnd, { + customTokenTexts: customTokenTextsRef.current, + }), }); const expandedSelectionOffsets = $readExpandedSelectionOffsetsFromEditorState({ start: clampExpandedCursor(nextValue, snapshotRef.current.expandedSelectionStart), @@ -1442,6 +1426,7 @@ function ComposerPromptEditorInner({ collapseExpandedComposerCursor( snapshotRef.current.value, snapshotRef.current.value.length, + { customTokenTexts: customTokenTextsRef.current }, ), ); }, @@ -1453,10 +1438,13 @@ function ComposerPromptEditorInner({ const handleEditorChange = useCallback((editorState: EditorState) => { editorState.read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); + const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor, { + customTokenTexts: customTokenTextsRef.current, + }); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), + { customTokenTexts: customTokenTextsRef.current }, ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, @@ -1467,8 +1455,12 @@ function ComposerPromptEditorInner({ $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const selectionOffsets = $readSelectionOffsetsFromEditorState({ - start: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionStart), - end: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionEnd), + start: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionStart, { + customTokenTexts: customTokenTextsRef.current, + }), + end: clampCollapsedComposerCursor(nextValue, snapshotRef.current.selectionEnd, { + customTokenTexts: customTokenTextsRef.current, + }), }); const expandedSelectionOffsets = $readExpandedSelectionOffsetsFromEditorState({ start: clampExpandedCursor(nextValue, snapshotRef.current.expandedSelectionStart), @@ -1503,8 +1495,12 @@ function ComposerPromptEditorInner({ terminalContextIds, }; const cursorAdjacentToMention = - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left", { + customTokenTexts: customTokenTextsRef.current, + }) || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right", { + customTokenTexts: customTokenTextsRef.current, + }); onChangeRef.current( nextValue, nextCursor, @@ -1543,8 +1539,11 @@ function ComposerPromptEditorInner({ /> - - + + @@ -1561,6 +1560,7 @@ export const ComposerPromptEditor = forwardRef< value, cursor, terminalContexts, + customTokenTexts = [], disabled, placeholder, className, @@ -1574,13 +1574,18 @@ export const ComposerPromptEditor = forwardRef< ) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); + const initialCustomTokenTextsRef = useRef(customTokenTexts); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerTerminalContextNode], + nodes: [ComposerMentionNode, ComposerCustomTokenNode, ComposerTerminalContextNode], editorState: () => { - $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current); + $setComposerEditorPrompt( + initialValueRef.current, + initialTerminalContextsRef.current, + initialCustomTokenTextsRef.current, + ); }, onError: (error) => { throw error; @@ -1595,6 +1600,7 @@ export const ComposerPromptEditor = forwardRef< value={value} cursor={cursor} terminalContexts={terminalContexts} + customTokenTexts={customTokenTexts} disabled={disabled} placeholder={placeholder} onRemoveTerminalContext={onRemoveTerminalContext} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b7b59eb36d..b5871dd408 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4,6 +4,7 @@ import type { ModelSelection, ProjectEntry, ProviderApprovalDecision, + ProviderCommandEntry, ProviderInteractionMode, ProviderKind, RuntimeMode, @@ -28,6 +29,7 @@ import { import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { providerCommandsQueryOptions } from "~/lib/providerCommandsReactQuery"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -98,7 +100,7 @@ import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import type { ComposerFileReference } from "../../t3code-custom/file-references"; -import { useComposerCustomExtension } from "../../t3code-custom/chat"; +import { useComposerCustomExtension, useComposerSkillExtension } from "../../t3code-custom/chat"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -126,6 +128,23 @@ const runtimeModeConfig: Record< const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; +const EMPTY_PROVIDER_COMMANDS: ReadonlyArray = Object.freeze([]); +const BUILT_IN_SLASH_COMMANDS = ["model", "plan", "default"] as const; + +function uniqueStrings(values: ReadonlyArray): string[] { + return [...new Set(values)]; +} + +function buildDefaultComposerPlaceholder(provider: ProviderKind, phase: SessionPhase): string { + if (phase === "disconnected") { + return provider === "codex" + ? "Ask for follow-up changes, type $ to mention skills, or attach images" + : "Ask for follow-up changes or attach images"; + } + return provider === "codex" + ? "Ask anything, @tag files/folders, type $ to mention skills, or use / to show available commands" + : "Ask anything, @tag files/folders, or use / to show available commands"; +} const extendReplacementRangeForTrailingSpace = ( text: string, @@ -329,6 +348,7 @@ export interface ChatComposerHandle { images: ComposerImageAttachment[]; fileReferences: ComposerFileReference[]; terminalContexts: TerminalContextDraft[]; + selectedSkills: Array<{ name: string; path: string }>; isResolvingFileReferences: boolean; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; @@ -640,15 +660,71 @@ export const ChatComposer = memo( [activeThreadActivities], ); + const providerCommandsQuery = useQuery( + providerCommandsQueryOptions({ + environmentId, + provider: selectedProvider, + cwd: gitCwd, + enabled: true, + }), + ); + const discoveredProviderCommands = + providerCommandsQuery.data?.commands ?? EMPTY_PROVIDER_COMMANDS; + const discoveredProviderSkills = providerCommandsQuery.data?.skills ?? EMPTY_PROVIDER_COMMANDS; + const discoveredSlashCommands = useMemo( + () => + uniqueStrings([ + ...BUILT_IN_SLASH_COMMANDS, + ...discoveredProviderCommands.map((entry) => entry.name), + ...(selectedProvider === "claudeAgent" + ? discoveredProviderSkills.map((entry) => entry.name) + : []), + ]), + [discoveredProviderCommands, discoveredProviderSkills, selectedProvider], + ); + const composerSkillExtension = useComposerSkillExtension({ + selectedProvider, + prompt, + discoveredProviderSkills, + }); + const composerCustomTokenTexts = composerSkillExtension.customTokenTexts; + const collapseComposerCursor = useCallback( + (text: string, cursorInput: number) => + collapseExpandedComposerCursor(text, cursorInput, { + customTokenTexts: composerCustomTokenTexts, + }), + [composerCustomTokenTexts], + ); + const clampComposerCursor = useCallback( + (text: string, cursorInput: number) => + clampCollapsedComposerCursor(text, cursorInput, { + customTokenTexts: composerCustomTokenTexts, + }), + [composerCustomTokenTexts], + ); + const expandComposerCursor = useCallback( + (text: string, cursorInput: number) => + expandCollapsedComposerCursor(text, cursorInput, { + customTokenTexts: composerCustomTokenTexts, + }), + [composerCustomTokenTexts], + ); + const detectComposerTriggerForContext = useCallback( + (text: string, cursorInput: number) => + detectComposerTrigger(text, cursorInput, { + slashCommands: discoveredSlashCommands, + enableSkillTrigger: selectedProvider === "codex", + }), + [discoveredSlashCommands, selectedProvider], + ); + // ------------------------------------------------------------------ // Composer-local state // ------------------------------------------------------------------ const [composerCursor, setComposerCursor] = useState(() => - collapseExpandedComposerCursor(prompt, prompt.length), - ); - const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), + collapseComposerCursor(prompt, prompt.length), ); + const [composerTrigger, setComposerTrigger] = useState(null); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); @@ -686,7 +762,9 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const skillTriggerQuery = composerTrigger?.kind === "skill" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; + const isSkillTrigger = composerTriggerKind === "skill"; const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( pathTriggerQuery, { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, @@ -717,7 +795,7 @@ export const ChatComposer = memo( })); } if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ + const slashCommandItems: ComposerCommandItem[] = [ { id: "slash:model", type: "slash-command", @@ -739,15 +817,42 @@ export const ChatComposer = memo( label: "/default", description: "Switch this thread back to normal build mode", }, - ] satisfies ReadonlyArray>; + ...discoveredProviderCommands.map((entry) => ({ + id: `slash:${selectedProvider}:${entry.source}:${entry.name}`, + type: "slash-command" as const, + command: entry.name, + label: `/${entry.name}`, + description: + entry.description || + (entry.source === "project" ? "Project command" : "User command"), + })), + ...(selectedProvider === "claudeAgent" + ? discoveredProviderSkills.map((entry) => ({ + id: `slash-skill:${selectedProvider}:${entry.source}:${entry.name}`, + type: "slash-command" as const, + command: entry.name, + label: `/${entry.name}`, + description: + entry.description || + (entry.source === "builtin" + ? "Built-in Claude command" + : entry.source === "project" + ? "Project Claude skill" + : "User Claude skill"), + })) + : []), + ]; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { - return [...slashCommandItems]; + return slashCommandItems; } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), + return slashCommandItems.filter((item) => + `${item.label} ${item.description}`.toLowerCase().includes(query), ); } + if (composerTrigger.kind === "skill") { + return composerSkillExtension.getSkillMenuItems(skillTriggerQuery); + } return searchableModelOptions .filter(({ searchSlug, searchName, searchProvider }) => { const query = composerTrigger.query.trim().toLowerCase(); @@ -766,7 +871,16 @@ export const ChatComposer = memo( label: name, description: `${providerLabel} ยท ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [ + composerTrigger, + composerSkillExtension, + discoveredProviderCommands, + discoveredProviderSkills, + searchableModelOptions, + selectedProvider, + skillTriggerQuery, + workspaceEntries, + ]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( @@ -819,10 +933,12 @@ export const ChatComposer = memo( ]); 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" || isSkillTrigger) && + (providerCommandsQuery.isLoading || providerCommandsQuery.isFetching)); // ------------------------------------------------------------------ // Provider traits UI @@ -836,12 +952,19 @@ export const ChatComposer = memo( } promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); - const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); + const nextCursor = collapseComposerCursor(nextPrompt, nextPrompt.length); setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + setComposerTrigger(detectComposerTriggerForContext(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [ + collapseComposerCursor, + composerDraftTarget, + detectComposerTriggerForContext, + promptRef, + scheduleComposerFocus, + setComposerDraftPrompt, + ], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -919,13 +1042,15 @@ export const ChatComposer = memo( promptRef.current = removal.prompt; setPrompt(removal.prompt); removeComposerDraftTerminalContext(composerDraftTarget, contextId); - const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); + const nextCursor = collapseComposerCursor(removal.prompt, removal.cursor); setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); + setComposerTrigger(detectComposerTriggerForContext(removal.prompt, removal.cursor)); }, [ + collapseComposerCursor, composerDraftTarget, composerTerminalContexts, + detectComposerTriggerForContext, promptRef, removeComposerDraftTerminalContext, setPrompt, @@ -937,8 +1062,8 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ useEffect(() => { promptRef.current = prompt; - setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt, promptRef]); + setComposerCursor((existing) => clampComposerCursor(prompt, existing)); + }, [clampComposerCursor, prompt, promptRef]); useEffect(() => { composerImagesRef.current = composerImages; @@ -996,12 +1121,12 @@ export const ChatComposer = memo( } promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); + const nextCursor = collapseComposerCursor(nextCustomAnswer, nextCustomAnswer.length); setComposerCursor(nextCursor); setComposerTrigger( - detectComposerTrigger( + detectComposerTriggerForContext( nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), + expandComposerCursor(nextCustomAnswer, nextCursor), ), ); setComposerHighlightedItemId(null); @@ -1009,6 +1134,9 @@ export const ChatComposer = memo( activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + collapseComposerCursor, + detectComposerTriggerForContext, + expandComposerCursor, promptRef, ]); @@ -1017,11 +1145,22 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ useEffect(() => { setComposerHighlightedItemId(null); - setComposerCursor( - collapseExpandedComposerCursor(promptRef.current, promptRef.current.length), + setComposerCursor(collapseComposerCursor(promptRef.current, promptRef.current.length)); + setComposerTrigger( + detectComposerTriggerForContext(promptRef.current, promptRef.current.length), ); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); - }, [draftId, activeThreadId, promptRef]); + }, [ + collapseComposerCursor, + detectComposerTriggerForContext, + draftId, + activeThreadId, + promptRef, + ]); + + useEffect(() => { + const expandedCursor = expandComposerCursor(promptRef.current, composerCursor); + setComposerTrigger(detectComposerTriggerForContext(promptRef.current, expandedCursor)); + }, [composerCursor, detectComposerTriggerForContext, expandComposerCursor, promptRef]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -1177,7 +1316,9 @@ export const ChatComposer = memo( if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention + ? null + : detectComposerTriggerForContext(nextPrompt, expandedCursor), ); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, @@ -1198,13 +1339,16 @@ export const ChatComposer = memo( } setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention + ? null + : detectComposerTriggerForContext(nextPrompt, expandedCursor), ); }, [ activePendingProgress?.activeQuestion, pendingUserInputs.length, onChangeActivePendingUserInputCustomAnswer, + detectComposerTriggerForContext, promptRef, setPrompt, composerDraftTarget, @@ -1233,8 +1377,8 @@ export const ChatComposer = memo( return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); - const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); + const nextCursor = collapseComposerCursor(next.text, next.cursor); + const nextExpandedCursor = expandComposerCursor(next.text, nextCursor); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -1249,7 +1393,7 @@ export const ChatComposer = memo( setPrompt(next.text); } setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); + setComposerTrigger(detectComposerTriggerForContext(next.text, nextExpandedCursor)); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCursor); }); @@ -1258,6 +1402,9 @@ export const ChatComposer = memo( [ activePendingProgress?.activeQuestion, activePendingUserInput, + collapseComposerCursor, + detectComposerTriggerForContext, + expandComposerCursor, onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, @@ -1278,7 +1425,7 @@ export const ChatComposer = memo( if (editorSnapshot) { return editorSnapshot; } - const expandedCursor = expandCollapsedComposerCursor(promptRef.current, composerCursor); + const expandedCursor = expandComposerCursor(promptRef.current, composerCursor); return { value: promptRef.current, cursor: composerCursor, @@ -1289,7 +1436,7 @@ export const ChatComposer = memo( expandedSelectionEnd: expandedCursor, terminalContextIds: composerTerminalContexts.map((context) => context.id), }; - }, [composerCursor, composerTerminalContexts, promptRef]); + }, [composerCursor, composerTerminalContexts, expandComposerCursor, promptRef]); const customExtension = useComposerCustomExtension({ composerDraftTarget, @@ -1304,11 +1451,11 @@ export const ChatComposer = memo( imageSizeLimitLabel: IMAGE_SIZE_LIMIT_LABEL, readComposerSnapshot, applyComposerPromptSnapshot: (nextPrompt, nextExpandedCursor) => { - const nextCollapsedCursor = collapseExpandedComposerCursor(nextPrompt, nextExpandedCursor); + const nextCollapsedCursor = collapseComposerCursor(nextPrompt, nextExpandedCursor); promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); setComposerCursor(nextCollapsedCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextExpandedCursor)); + setComposerTrigger(detectComposerTriggerForContext(nextPrompt, nextExpandedCursor)); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCollapsedCursor); }); @@ -1328,9 +1475,9 @@ export const ChatComposer = memo( const snapshot = readComposerSnapshot(); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + trigger: detectComposerTriggerForContext(snapshot.value, snapshot.expandedCursor), }; - }, [readComposerSnapshot]); + }, [detectComposerTriggerForContext, readComposerSnapshot]); const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { @@ -1378,15 +1525,58 @@ export const ChatComposer = memo( } return; } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); + if (item.command === "plan" || item.command === "default") { + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + const replacement = `/${item.command} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); if (applied) { setComposerHighlightedItemId(null); } return; } + if (item.type === "skill") { + const replacement = `$${item.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const next = replaceTextRange( + snapshot.value, + trigger.rangeStart, + replacementRangeEnd, + replacement, + ); + const nextCursor = collapseComposerCursor(next.text, next.cursor); + const nextExpandedCursor = expandComposerCursor(next.text, nextCursor); + promptRef.current = next.text; + setPrompt(next.text); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTriggerForContext(next.text, nextExpandedCursor)); + setComposerHighlightedItemId(null); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCursor); + }); + return; + } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -1397,9 +1587,14 @@ export const ChatComposer = memo( }, [ applyPromptReplacement, + collapseComposerCursor, + detectComposerTriggerForContext, + expandComposerCursor, handleInteractionModeChange, onProviderModelSelect, + promptRef, resolveActiveComposerTrigger, + setPrompt, ], ); @@ -1488,14 +1683,14 @@ export const ChatComposer = memo( detectTrigger?: boolean; }) => { const promptForState = options?.prompt ?? promptRef.current; - const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); + const cursor = clampComposerCursor(promptForState, options?.cursor ?? 0); setComposerHighlightedItemId(null); setComposerCursor(cursor); setComposerTrigger( options?.detectTrigger - ? detectComposerTrigger( + ? detectComposerTriggerForContext( promptForState, - expandCollapsedComposerCursor(promptForState, cursor), + expandComposerCursor(promptForState, cursor), ) : null, ); @@ -1505,17 +1700,14 @@ export const ChatComposer = memo( const snapshot = composerEditorRef.current?.readSnapshot() ?? { value: promptRef.current, cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + expandedCursor: expandComposerCursor(promptRef.current, composerCursor), terminalContextIds: composerTerminalContexts.map((context) => context.id), }; const insertion = insertInlineTerminalContextPlaceholder( snapshot.value, snapshot.expandedCursor, ); - const nextCollapsedCursor = collapseExpandedComposerCursor( - insertion.prompt, - insertion.cursor, - ); + const nextCollapsedCursor = collapseComposerCursor(insertion.prompt, insertion.cursor); const inserted = insertComposerDraftTerminalContext( composerDraftTarget, insertion.prompt, @@ -1530,7 +1722,7 @@ export const ChatComposer = memo( if (!inserted) return; promptRef.current = insertion.prompt; setComposerCursor(nextCollapsedCursor); - setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); + setComposerTrigger(detectComposerTriggerForContext(insertion.prompt, insertion.cursor)); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCollapsedCursor); }); @@ -1540,6 +1732,7 @@ export const ChatComposer = memo( images: composerImagesRef.current, fileReferences: composerFileReferencesRef.current, terminalContexts: composerTerminalContextsRef.current, + selectedSkills: composerSkillExtension.selectedSkills, isResolvingFileReferences: customExtension.isResolvingFileReferences, selectedPromptEffort, selectedModelOptionsForDispatch, @@ -1551,6 +1744,8 @@ export const ChatComposer = memo( }), [ activeThread, + clampComposerCursor, + collapseComposerCursor, composerDraftTarget, composerCursor, composerTerminalContexts, @@ -1559,7 +1754,10 @@ export const ChatComposer = memo( composerImagesRef, composerFileReferencesRef, composerTerminalContextsRef, + composerSkillExtension.selectedSkills, + detectComposerTriggerForContext, customExtension.isResolvingFileReferences, + expandComposerCursor, readComposerSnapshot, selectedModel, selectedModelOptionsForDispatch, @@ -1731,6 +1929,7 @@ export const ChatComposer = memo( ? composerTerminalContexts : [] } + customTokenTexts={composerCustomTokenTexts} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} onCommandKeyDown={onComposerCommandKey} @@ -1743,9 +1942,7 @@ export const ChatComposer = memo( ? "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" - : phase === "disconnected" - ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, or use / to show available commands" + : buildDefaultComposerPlaceholder(selectedProvider, phase) } disabled={isConnecting || isComposerApprovalState} /> diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index fc7ea27c29..610cb7d76d 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,10 +1,11 @@ import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; import { memo, useLayoutEffect, useRef } from "react"; -import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; +import { type ComposerTriggerKind } from "../../composer-logic"; import { BotIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandItem, CommandList } from "../ui/command"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; export type ComposerCommandItem = @@ -19,7 +20,16 @@ export type ComposerCommandItem = | { id: string; type: "slash-command"; - command: ComposerSlashCommand; + command: string; + label: string; + description: string; + } + | { + id: string; + type: "skill"; + provider: ProviderKind; + name: string; + path: string; label: string; description: string; } @@ -80,10 +90,16 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {props.items.length === 0 && (

{props.isLoading - ? "Searching workspace files..." + ? props.triggerKind === "path" + ? "Searching workspace files..." + : props.triggerKind === "skill" + ? "Loading skills..." + : "Loading commands..." : props.triggerKind === "path" ? "No matching files or folders." - : "No matching command."} + : props.triggerKind === "skill" + ? "No matching skill." + : "No matching command."}

)} @@ -98,7 +114,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { onHighlight: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { - return ( + const itemElement = ( ) : null} - + {props.item.label} - {props.item.description} + + {props.item.description} + ); + + if (props.item.type !== "skill") { + return itemElement; + } + + return ( + + + +
{props.item.label}
+
{props.item.description}
+
+
+ ); }); diff --git a/apps/web/src/components/composerInlineTextNodes.ts b/apps/web/src/components/composerInlineTextNodes.ts new file mode 100644 index 0000000000..00c3f35f35 --- /dev/null +++ b/apps/web/src/components/composerInlineTextNodes.ts @@ -0,0 +1,221 @@ +import { + $applyNodeReplacement, + TextNode, + type EditorConfig, + type NodeKey, + type SerializedTextNode, + type Spread, +} from "lexical"; + +import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; + +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "./composerInlineChip"; + +type SerializedComposerMentionNode = Spread< + { + path: string; + type: "composer-mention"; + version: 1; + }, + SerializedTextNode +>; + +type SerializedComposerCustomTokenNode = Spread< + { + tokenText: string; + type: "composer-custom-token"; + version: 1; + }, + SerializedTextNode +>; + +function resolvedThemeFromDocument(): "light" | "dark" { + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + +function renderMentionChipDom(container: HTMLElement, pathValue: string): void { + container.textContent = ""; + container.style.setProperty("user-select", "none"); + container.style.setProperty("-webkit-user-select", "none"); + + const theme = resolvedThemeFromDocument(); + const icon = document.createElement("img"); + icon.alt = ""; + icon.ariaHidden = "true"; + icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; + icon.loading = "lazy"; + icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); + + const label = document.createElement("span"); + label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; + label.textContent = basenameOfPath(pathValue); + + container.append(icon, label); +} + +function renderCustomTokenChipDom(container: HTMLElement, tokenText: string): void { + container.textContent = ""; + container.style.setProperty("user-select", "none"); + container.style.setProperty("-webkit-user-select", "none"); + + const label = document.createElement("span"); + label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; + label.textContent = tokenText; + + container.append(label); +} + +export class ComposerMentionNode extends TextNode { + __path: string; + + static override getType(): string { + return "composer-mention"; + } + + static override clone(node: ComposerMentionNode): ComposerMentionNode { + return new ComposerMentionNode(node.__path, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { + return $createComposerMentionNode(serializedNode.path); + } + + constructor(path: string, key?: NodeKey) { + const normalizedPath = path.startsWith("@") ? path.slice(1) : path; + super(`@${normalizedPath}`, key); + this.__path = normalizedPath; + } + + override exportJSON(): SerializedComposerMentionNode { + return { + ...super.exportJSON(), + path: this.__path, + type: "composer-mention", + version: 1, + }; + } + + override createDOM(_config: EditorConfig): HTMLElement { + const dom = document.createElement("span"); + dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + renderMentionChipDom(dom, this.__path); + return dom; + } + + override updateDOM( + prevNode: ComposerMentionNode, + dom: HTMLElement, + _config: EditorConfig, + ): boolean { + dom.contentEditable = "false"; + if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { + renderMentionChipDom(dom, this.__path); + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +export function $createComposerMentionNode(path: string): ComposerMentionNode { + return $applyNodeReplacement(new ComposerMentionNode(path)); +} + +export class ComposerCustomTokenNode extends TextNode { + __tokenText: string; + + static override getType(): string { + return "composer-custom-token"; + } + + static override clone(node: ComposerCustomTokenNode): ComposerCustomTokenNode { + return new ComposerCustomTokenNode(node.__tokenText, node.__key); + } + + static override importJSON( + serializedNode: SerializedComposerCustomTokenNode, + ): ComposerCustomTokenNode { + return $createComposerCustomTokenNode(serializedNode.tokenText); + } + + constructor(tokenText: string, key?: NodeKey) { + super(tokenText, key); + this.__tokenText = tokenText; + } + + override exportJSON(): SerializedComposerCustomTokenNode { + return { + ...super.exportJSON(), + tokenText: this.__tokenText, + type: "composer-custom-token", + version: 1, + }; + } + + override createDOM(_config: EditorConfig): HTMLElement { + const dom = document.createElement("span"); + dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + renderCustomTokenChipDom(dom, this.__tokenText); + return dom; + } + + override updateDOM( + prevNode: ComposerCustomTokenNode, + dom: HTMLElement, + _config: EditorConfig, + ): boolean { + dom.contentEditable = "false"; + if (prevNode.__text !== this.__text || prevNode.__tokenText !== this.__tokenText) { + renderCustomTokenChipDom(dom, this.__tokenText); + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +export function $createComposerCustomTokenNode(tokenText: string): ComposerCustomTokenNode { + return $applyNodeReplacement(new ComposerCustomTokenNode(tokenText)); +} + +export type ComposerInlineTextNode = ComposerMentionNode | ComposerCustomTokenNode; + +export function isComposerInlineTextNode(candidate: unknown): candidate is ComposerInlineTextNode { + return candidate instanceof ComposerMentionNode || candidate instanceof ComposerCustomTokenNode; +} diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index e42dac977e..e02f2981f2 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -29,6 +29,34 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); + it("splits codex skill tokens followed by whitespace into skill segments", () => { + expect( + splitPromptIntoComposerSegments("Use $review please", [], { + customTokenTexts: ["$review"], + }), + ).toEqual([ + { type: "text", text: "Use " }, + { type: "custom-token", tokenText: "$review" }, + { type: "text", text: " please" }, + ]); + }); + + it("does not convert an incomplete trailing skill token", () => { + expect( + splitPromptIntoComposerSegments("Use $review", [], { + customTokenTexts: ["$review"], + }), + ).toEqual([{ type: "text", text: "Use $review" }]); + }); + + it("keeps unknown shell-style variables as text", () => { + expect( + splitPromptIntoComposerSegments("echo $HOME please", [], { + customTokenTexts: ["$review"], + }), + ).toEqual([{ type: "text", text: "echo $HOME please" }]); + }); + it("keeps inline terminal context placeholders at their prompt positions", () => { expect( splitPromptIntoComposerSegments( @@ -96,4 +124,15 @@ describe("selectionTouchesMentionBoundary", () => { ), ).toBe(true); }); + + it("returns true when selection includes whitespace after a skill token", () => { + expect( + selectionTouchesMentionBoundary( + "use $review now", + "use $review".length, + "use $review now".length, + { customTokenTexts: ["$review"] }, + ), + ).toBe(true); + }); }); diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index ee70c6d741..54698c4c62 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -3,6 +3,10 @@ import { type TerminalContextDraft, } from "./lib/terminalContext"; +export interface ComposerPromptInlineTokenOptions { + customTokenTexts?: readonly string[]; +} + export type ComposerPromptSegment = | { type: "text"; @@ -12,6 +16,10 @@ export type ComposerPromptSegment = type: "mention"; path: string; } + | { + type: "custom-token"; + tokenText: string; + } | { type: "terminal-context"; context: TerminalContextDraft | null; @@ -19,6 +27,24 @@ export type ComposerPromptSegment = const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; +function toCustomTokenTexts(tokenTexts?: readonly string[]): readonly string[] { + if (!tokenTexts || tokenTexts.length === 0) { + return []; + } + return [...new Set(tokenTexts)].toSorted((left, right) => right.length - left.length); +} + +function escapeRegexFragment(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildCustomTokenRegex(tokenTexts: readonly string[]): RegExp | null { + if (tokenTexts.length === 0) { + return null; + } + return new RegExp(`(^|\\s)(${tokenTexts.map(escapeRegexFragment).join("|")})(?=\\s)`, "g"); +} + function rangeIncludesIndex(start: number, end: number, index: number): boolean { return start <= index && index < end; } @@ -111,32 +137,85 @@ function forEachMentionMatch( }); } -function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] { +function forEachCustomTokenMatch( + prompt: string, + customTokenRegex: RegExp | null, + visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void, +): boolean { + if (customTokenRegex === null) { + return false; + } + return forEachPromptTextSlice(prompt, (text, promptOffset) => { + for (const match of text.matchAll(customTokenRegex)) { + if (visitor(match, promptOffset) === true) { + return true; + } + } + return false; + }); +} + +function splitPromptTextIntoComposerSegments( + text: string, + customTokenRegex: RegExp | null, +): ComposerPromptSegment[] { const segments: ComposerPromptSegment[] = []; if (!text) { return segments; } - let cursor = 0; - for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const path = match[2] ?? ""; - const matchIndex = match.index ?? 0; - const mentionStart = matchIndex + prefix.length; - const mentionEnd = mentionStart + fullMatch.length - prefix.length; + const matches = [ + ...Array.from(text.matchAll(MENTION_TOKEN_REGEX), (match) => { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const path = match[2] ?? ""; + const matchIndex = match.index ?? 0; + const start = matchIndex + prefix.length; + const end = start + fullMatch.length - prefix.length; + return { type: "mention" as const, value: path, start, end }; + }), + ...Array.from(text.matchAll(customTokenRegex ?? /$^/g), (match) => { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const tokenText = match[2] ?? ""; + const matchIndex = match.index ?? 0; + const start = matchIndex + prefix.length; + const end = start + fullMatch.length - prefix.length; + return { type: "custom-token" as const, value: tokenText, start, end }; + }), + ] + .filter( + ( + match, + ): match is { + type: "mention" | "custom-token"; + value: string; + start: number; + end: number; + } => match !== null, + ) + .toSorted((left, right) => left.start - right.start); - if (mentionStart > cursor) { - pushTextSegment(segments, text.slice(cursor, mentionStart)); + let cursor = 0; + for (const match of matches) { + if (match.start < cursor) { + continue; } - - if (path.length > 0) { - segments.push({ type: "mention", path }); + if (match.start > cursor) { + pushTextSegment(segments, text.slice(cursor, match.start)); + } + if (match.type === "mention") { + if (match.value.length > 0) { + segments.push({ type: "mention", path: match.value }); + } else { + pushTextSegment(segments, text.slice(match.start, match.end)); + } + } else if (match.value.length > 0) { + segments.push({ type: "custom-token", tokenText: match.value }); } else { - pushTextSegment(segments, text.slice(mentionStart, mentionEnd)); + pushTextSegment(segments, text.slice(match.start, match.end)); } - - cursor = mentionEnd; + cursor = match.end; } if (cursor < text.length) { @@ -150,42 +229,56 @@ export function selectionTouchesMentionBoundary( prompt: string, start: number, end: number, + options?: ComposerPromptInlineTokenOptions, ): boolean { if (!prompt || start >= end) { return false; } + const customTokenRegex = buildCustomTokenRegex(toCustomTokenTexts(options?.customTokenTexts)); - return forEachMentionMatch(prompt, (match, promptOffset) => { + const touchesBoundary = ( + match: RegExpMatchArray, + promptOffset: number, + prefixGroupIndex: number, + ) => { const fullMatch = match[0]; - const prefix = match[1] ?? ""; + const prefix = match[prefixGroupIndex] ?? ""; const matchIndex = match.index ?? 0; - const mentionStart = promptOffset + matchIndex + prefix.length; - const mentionEnd = mentionStart + fullMatch.length - prefix.length; - const beforeMentionIndex = mentionStart - 1; - const afterMentionIndex = mentionEnd; + const tokenStart = promptOffset + matchIndex + prefix.length; + const tokenEnd = tokenStart + fullMatch.length - prefix.length; + const beforeTokenIndex = tokenStart - 1; + const afterTokenIndex = tokenEnd; if ( - beforeMentionIndex >= 0 && - /\s/.test(prompt[beforeMentionIndex] ?? "") && - rangeIncludesIndex(start, end, beforeMentionIndex) + beforeTokenIndex >= 0 && + /\s/.test(prompt[beforeTokenIndex] ?? "") && + rangeIncludesIndex(start, end, beforeTokenIndex) ) { return true; } if ( - afterMentionIndex < prompt.length && - /\s/.test(prompt[afterMentionIndex] ?? "") && - rangeIncludesIndex(start, end, afterMentionIndex) + afterTokenIndex < prompt.length && + /\s/.test(prompt[afterTokenIndex] ?? "") && + rangeIncludesIndex(start, end, afterTokenIndex) ) { return true; } return false; - }); + }; + + return ( + forEachMentionMatch(prompt, (match, promptOffset) => touchesBoundary(match, promptOffset, 1)) || + forEachCustomTokenMatch(prompt, customTokenRegex, (match, promptOffset) => + touchesBoundary(match, promptOffset, 1), + ) + ); } export function splitPromptIntoComposerSegments( prompt: string, terminalContexts: ReadonlyArray = [], + options?: ComposerPromptInlineTokenOptions, ): ComposerPromptSegment[] { if (!prompt) { return []; @@ -193,9 +286,10 @@ export function splitPromptIntoComposerSegments( const segments: ComposerPromptSegment[] = []; let terminalContextIndex = 0; + const customTokenRegex = buildCustomTokenRegex(toCustomTokenTexts(options?.customTokenTexts)); forEachPromptSegmentSlice(prompt, (slice) => { if (slice.type === "text") { - segments.push(...splitPromptTextIntoComposerSegments(slice.text)); + segments.push(...splitPromptTextIntoComposerSegments(slice.text, customTokenRegex)); return false; } diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 44f32bef9a..ce693a7612 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -60,6 +60,39 @@ describe("detectComposerTrigger", () => { }); }); + it("detects provider-discovered slash commands while typing", () => { + const text = "/sim"; + const trigger = detectComposerTrigger(text, text.length, { + slashCommands: ["model", "plan", "default", "simplify"], + }); + + expect(trigger).toEqual({ + kind: "slash-command", + query: "sim", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("detects codex skill trigger while typing", () => { + const text = "Use $rev"; + const cursor = text.length; + const trigger = detectComposerTrigger(text, cursor, { enableSkillTrigger: true }); + + expect(trigger).toEqual({ + kind: "skill", + query: "rev", + rangeStart: "Use ".length, + rangeEnd: cursor, + }); + }); + + it("does not collapse shell variables into inline tokens without a known skill catalog", () => { + expect(collapseExpandedComposerCursor("echo $HOME ", "echo $HOME ".length)).toBe( + "echo $HOME ".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"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c8e62ebdcc..05023be34c 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,7 +1,10 @@ -import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; +import { + type ComposerPromptInlineTokenOptions, + splitPromptIntoComposerSegments, +} from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; -export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; +export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; export interface ComposerTrigger { @@ -13,7 +16,11 @@ export interface ComposerTrigger { const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; const isInlineTokenSegment = ( - segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, + segment: + | { type: "text"; text: string } + | { type: "mention" } + | { type: "custom-token"; tokenText: string } + | { type: "terminal-context" }, ): boolean => segment.type !== "text"; function clampCursor(text: string, cursor: number): number { @@ -39,9 +46,13 @@ function tokenStartForCursor(text: string, cursor: number): number { return index + 1; } -export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { +export function expandCollapsedComposerCursor( + text: string, + cursorInput: number, + options?: ComposerPromptInlineTokenOptions, +): number { const collapsedCursor = clampCursor(text, cursorInput); - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments(text, [], options); if (segments.length === 0) { return collapsedCursor; } @@ -50,8 +61,9 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) let expandedCursor = 0; for (const segment of segments) { - if (segment.type === "mention") { - const expandedLength = segment.path.length + 1; + if (segment.type === "mention" || segment.type === "custom-token") { + const expandedLength = + segment.type === "mention" ? segment.path.length + 1 : segment.tokenText.length; if (remaining <= 1) { return expandedCursor + (remaining === 0 ? 0 : expandedLength); } @@ -80,7 +92,11 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) } function collapsedSegmentLength( - segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, + segment: + | { type: "text"; text: string } + | { type: "mention" } + | { type: "custom-token"; tokenText: string } + | { type: "terminal-context" }, ): number { if (segment.type === "text") { return segment.text.length; @@ -90,7 +106,10 @@ function collapsedSegmentLength( function clampCollapsedComposerCursorForSegments( segments: ReadonlyArray< - { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" } + | { type: "text"; text: string } + | { type: "mention" } + | { type: "custom-token"; tokenText: string } + | { type: "terminal-context" } >, cursorInput: number, ): number { @@ -104,16 +123,24 @@ function clampCollapsedComposerCursorForSegments( return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); } -export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { +export function clampCollapsedComposerCursor( + text: string, + cursorInput: number, + options?: ComposerPromptInlineTokenOptions, +): number { return clampCollapsedComposerCursorForSegments( - splitPromptIntoComposerSegments(text), + splitPromptIntoComposerSegments(text, [], options), cursorInput, ); } -export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { +export function collapseExpandedComposerCursor( + text: string, + cursorInput: number, + options?: ComposerPromptInlineTokenOptions, +): number { const expandedCursor = clampCursor(text, cursorInput); - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments(text, [], options); if (segments.length === 0) { return expandedCursor; } @@ -122,8 +149,9 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number let collapsedCursor = 0; for (const segment of segments) { - if (segment.type === "mention") { - const expandedLength = segment.path.length + 1; + if (segment.type === "mention" || segment.type === "custom-token") { + const expandedLength = + segment.type === "mention" ? segment.path.length + 1 : segment.tokenText.length; if (remaining === 0) { return collapsedCursor; } @@ -158,8 +186,9 @@ export function isCollapsedCursorAdjacentToInlineToken( text: string, cursorInput: number, direction: "left" | "right", + options?: ComposerPromptInlineTokenOptions, ): boolean { - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments(text, [], options); if (!segments.some(isInlineTokenSegment)) { return false; } @@ -184,10 +213,18 @@ export function isCollapsedCursorAdjacentToInlineToken( export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; -export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { +export function detectComposerTrigger( + text: string, + cursorInput: number, + options?: { + slashCommands?: readonly string[]; + enableSkillTrigger?: boolean; + }, +): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; const linePrefix = text.slice(lineStart, cursor); + const slashCommands = options?.slashCommands ?? SLASH_COMMANDS; if (linePrefix.startsWith("/")) { const commandMatch = /^\/(\S*)$/.exec(linePrefix); @@ -201,7 +238,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { + if (slashCommands.some((command) => command.startsWith(commandQuery.toLowerCase()))) { return { kind: "slash-command", query: commandQuery, @@ -225,6 +262,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); + if (options?.enableSkillTrigger && token.startsWith("$")) { + return { + kind: "skill", + query: token.slice(1), + rangeStart: tokenStart, + rangeEnd: cursor, + }; + } if (!token.startsWith("@")) { return null; } diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 5f23a53a75..4c6b1bd759 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -16,6 +16,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { }, projects: { searchEntries: rpcClient.projects.searchEntries, + listProviderCommands: rpcClient.projects.listProviderCommands, writeFile: rpcClient.projects.writeFile, }, git: { diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 4dc6501549..d3d103240b 100644 --- a/apps/web/src/environments/runtime/connection.test.ts +++ b/apps/web/src/environments/runtime/connection.test.ts @@ -76,6 +76,7 @@ function createTestClient(options?: { }, projects: { searchEntries: vi.fn(async () => []), + listProviderCommands: vi.fn(async () => ({ provider: "codex", commands: [], skills: [] })), writeFile: vi.fn(async () => undefined), }, shell: { diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index b5317d9ec1..35f4771b41 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -84,6 +84,7 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { }, projects: { searchEntries: vi.fn(async () => []), + listProviderCommands: vi.fn(async () => ({ provider: "codex", commands: [], skills: [] })), writeFile: vi.fn(async () => undefined), }, shell: { diff --git a/apps/web/src/lib/providerCommandsReactQuery.ts b/apps/web/src/lib/providerCommandsReactQuery.ts new file mode 100644 index 0000000000..2ed5ae80f2 --- /dev/null +++ b/apps/web/src/lib/providerCommandsReactQuery.ts @@ -0,0 +1,44 @@ +import type { EnvironmentId, ProviderCommandsListResult, ProviderKind } from "@t3tools/contracts"; +import { queryOptions } from "@tanstack/react-query"; +import { ensureEnvironmentApi } from "~/environmentApi"; + +const EMPTY_PROVIDER_COMMANDS_RESULT: ProviderCommandsListResult = { + provider: "codex", + commands: [], + skills: [], +}; + +export const providerCommandsQueryKeys = { + all: ["provider-commands"] as const, + list: (environmentId: EnvironmentId | null, provider: ProviderKind, cwd: string | null) => + ["provider-commands", environmentId ?? null, provider, cwd] as const, +}; + +export function providerCommandsQueryOptions(input: { + environmentId: EnvironmentId | null; + provider: ProviderKind; + cwd: string | null; + enabled?: boolean; + staleTime?: number; +}) { + return queryOptions({ + queryKey: providerCommandsQueryKeys.list(input.environmentId, input.provider, input.cwd), + queryFn: async () => { + if (!input.environmentId) { + throw new Error("Provider command discovery is unavailable."); + } + const api = ensureEnvironmentApi(input.environmentId); + return api.projects.listProviderCommands({ + provider: input.provider, + ...(input.cwd ? { cwd: input.cwd } : {}), + }); + }, + enabled: (input.enabled ?? true) && input.environmentId !== null, + staleTime: input.staleTime ?? 15_000, + placeholderData: (previous) => + previous ?? { + ...EMPTY_PROVIDER_COMMANDS_RESULT, + provider: input.provider, + }, + }); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9b9feabfb3..2f53db0d4f 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -50,6 +50,7 @@ const rpcClientMock = { }, projects: { searchEntries: vi.fn(), + listProviderCommands: vi.fn(), writeFile: vi.fn(), }, shell: { diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index b714889b7a..f5ba2488ca 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -57,6 +57,7 @@ export interface WsRpcClient { }; readonly projects: { readonly searchEntries: RpcUnaryMethod; + readonly listProviderCommands: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; }; readonly shell: { @@ -134,6 +135,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { projects: { searchEntries: (input) => transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), + listProviderCommands: (input) => + transport.request((client) => client[WS_METHODS.projectsListProviderCommands](input)), writeFile: (input) => transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), }, diff --git a/apps/web/src/t3code-custom/chat/index.ts b/apps/web/src/t3code-custom/chat/index.ts index b8f4b5dc2d..fdad7caa9c 100644 --- a/apps/web/src/t3code-custom/chat/index.ts +++ b/apps/web/src/t3code-custom/chat/index.ts @@ -2,3 +2,4 @@ export { ComposerCustomBodySlot } from "./ComposerCustomBodySlot"; export { ComposerCustomControlsSlot } from "./ComposerCustomControlsSlot"; export { ComposerThreadLoopSlot } from "./ComposerThreadLoopSlot"; export { useComposerCustomExtension } from "./useComposerCustomExtension"; +export { useComposerSkillExtension } from "./useComposerSkillExtension"; diff --git a/apps/web/src/t3code-custom/chat/useComposerSkillExtension.ts b/apps/web/src/t3code-custom/chat/useComposerSkillExtension.ts new file mode 100644 index 0000000000..30fc187f94 --- /dev/null +++ b/apps/web/src/t3code-custom/chat/useComposerSkillExtension.ts @@ -0,0 +1,100 @@ +import type { ProviderCommandEntry, ProviderKind } from "@t3tools/contracts"; +import { useCallback, useMemo } from "react"; + +import type { ComposerCommandItem } from "~/components/chat/ComposerCommandMenu"; +import { + deriveComposerSkillSelections, + toCodexSkillReferencesForSend, +} from "~/codexSkillSelections"; + +const EMPTY_SKILL_NAMES: ReadonlyArray = Object.freeze([]); +const EMPTY_DISCOVERED_SKILLS: ReadonlyArray = Object.freeze([]); +const EMPTY_SKILL_MENU_ITEMS: ReadonlyArray = Object.freeze([]); +const EMPTY_SELECTED_SKILLS: ReadonlyArray<{ name: string; path: string }> = Object.freeze([]); + +export function useComposerSkillExtension(input: { + selectedProvider: ProviderKind; + prompt: string; + discoveredProviderSkills: ReadonlyArray; +}) { + const { selectedProvider, prompt, discoveredProviderSkills } = input; + + const codexDiscoveredSkills = useMemo( + () => + selectedProvider === "codex" + ? discoveredProviderSkills.filter((entry) => typeof entry.path === "string") + : EMPTY_DISCOVERED_SKILLS, + [discoveredProviderSkills, selectedProvider], + ); + + const skillNames = useMemo( + () => + selectedProvider === "codex" + ? codexDiscoveredSkills.map((entry) => entry.name) + : EMPTY_SKILL_NAMES, + [codexDiscoveredSkills, selectedProvider], + ); + const customTokenTexts = useMemo( + () => skillNames.map((skillName) => `$${skillName}`), + [skillNames], + ); + + const skillSelections = useMemo( + () => + selectedProvider === "codex" + ? deriveComposerSkillSelections({ + prompt, + availableSkills: codexDiscoveredSkills, + }) + : [], + [codexDiscoveredSkills, prompt, selectedProvider], + ); + + const getSkillMenuItems = useCallback( + (query: string): ComposerCommandItem[] => { + if (selectedProvider !== "codex") { + return [...EMPTY_SKILL_MENU_ITEMS]; + } + const normalizedQuery = query.trim().toLowerCase(); + const filteredSkills = !normalizedQuery + ? codexDiscoveredSkills + : codexDiscoveredSkills.filter((entry) => + `${entry.name} ${entry.description ?? ""} ${entry.path ?? ""}` + .toLowerCase() + .includes(normalizedQuery), + ); + return filteredSkills.flatMap((entry) => + entry.path + ? [ + { + id: `skill:${entry.source}:${entry.path}`, + type: "skill" as const, + provider: selectedProvider, + name: entry.name, + path: entry.path, + label: `$${entry.name}`, + description: + entry.description || + (entry.source === "project" ? entry.path : `${entry.source} ยท ${entry.path}`), + }, + ] + : [], + ); + }, + [codexDiscoveredSkills, selectedProvider], + ); + + const selectedSkills = useMemo( + () => + selectedProvider === "codex" + ? toCodexSkillReferencesForSend(skillSelections) + : [...EMPTY_SELECTED_SKILLS], + [selectedProvider, skillSelections], + ); + + return { + customTokenTexts, + selectedSkills, + getSkillMenuItems, + }; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f12cf80d57..69e571523a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,7 @@ export * from "./environment"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; +export * from "./providerCommands"; export * from "./providerRuntime"; export * from "./model"; export * from "./keybindings"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7cf55e851a..6a709d2979 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -24,6 +24,7 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import type { ProviderCommandsListInput, ProviderCommandsListResult } from "./providerCommands"; import type { ServerConfig, ServerProviderUpdatedPayload, @@ -230,6 +231,7 @@ export interface EnvironmentApi { }; projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; + listProviderCommands: (input: ProviderCommandsListInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; git: { diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 2d87b4a516..55fd18d2c2 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -236,6 +236,28 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => }), ); +it.effect("accepts structured skills in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-skills", + threadId: "thread-1", + message: { + messageId: "msg-skills", + role: "user", + text: "Use the review skill", + attachments: [], + }, + skills: [{ name: "review", path: "/Users/example/.codex/skills/review" }], + createdAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(parsed.skills, [ + { name: "review", path: "/Users/example/.codex/skills/review" }, + ]); + }), +); + it.effect("decodes thread.created runtime mode for historical events", () => Effect.gen(function* () { const parsed = yield* decodeThreadCreatedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4f01009814..57c80039f9 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,6 +1,7 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; import { ClaudeModelOptions, CodexModelOptions } from "./model"; import { RepositoryIdentity } from "./environment"; +import { ProviderSkillReference } from "./providerCommands"; import { ApprovalRequestId, CheckpointRef, @@ -463,6 +464,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + skills: Schema.optional(Schema.Array(ProviderSkillReference)), titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), interactionMode: ProviderInteractionMode.pipe( @@ -484,6 +486,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(UploadChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + skills: Schema.optional(Schema.Array(ProviderSkillReference)), titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -814,6 +817,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), + skills: Schema.optional(Schema.Array(ProviderSkillReference)), titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 37469984de..830b7b6bcc 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -74,6 +74,7 @@ describe("ProviderSendTurnInput", () => { it("accepts codex modelSelection", () => { const parsed = decodeProviderSendTurnInput({ threadId: "thread-1", + skills: [{ name: "review", path: "/Users/example/.codex/skills/review" }], modelSelection: { provider: "codex", model: "gpt-5.3-codex", @@ -86,6 +87,9 @@ describe("ProviderSendTurnInput", () => { expect(parsed.modelSelection?.provider).toBe("codex"); expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); + expect(parsed.skills).toEqual([ + { name: "review", path: "/Users/example/.codex/skills/review" }, + ]); if (parsed.modelSelection?.provider !== "codex") { throw new Error("Expected codex modelSelection"); } diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 16102920d7..1649c855b9 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; +import { ProviderSkillReference } from "./providerCommands"; import { ApprovalRequestId, EventId, @@ -66,6 +67,7 @@ export const ProviderSendTurnInput = Schema.Struct({ attachments: Schema.optional( Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), ), + skills: Schema.optional(Schema.Array(ProviderSkillReference)), modelSelection: Schema.optional(ModelSelection), interactionMode: Schema.optional(ProviderInteractionMode), }); diff --git a/packages/contracts/src/providerCommands.test.ts b/packages/contracts/src/providerCommands.test.ts new file mode 100644 index 0000000000..8f1844f5c1 --- /dev/null +++ b/packages/contracts/src/providerCommands.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { ProviderCommandsListInput, ProviderCommandsListResult } from "./providerCommands"; + +const decodeProviderCommandsListInput = Schema.decodeUnknownSync(ProviderCommandsListInput); +const decodeProviderCommandsListResult = Schema.decodeUnknownSync(ProviderCommandsListResult); + +describe("ProviderCommandsListInput", () => { + it("accepts provider-aware discovery requests", () => { + const parsed = decodeProviderCommandsListInput({ + provider: "codex", + cwd: "/tmp/workspace", + }); + + expect(parsed).toEqual({ + provider: "codex", + cwd: "/tmp/workspace", + }); + }); +}); + +describe("ProviderCommandsListResult", () => { + it("accepts commands and skills with optional paths", () => { + const parsed = decodeProviderCommandsListResult({ + provider: "claudeAgent", + commands: [{ name: "simplify", source: "builtin", description: "Review code" }], + skills: [ + { + name: "design-review", + source: "project", + path: "/tmp/project/.claude/skills/design-review", + }, + ], + }); + + expect(parsed.provider).toBe("claudeAgent"); + expect(parsed.commands[0]?.name).toBe("simplify"); + expect(parsed.skills[0]?.path).toBe("/tmp/project/.claude/skills/design-review"); + }); +}); diff --git a/packages/contracts/src/providerCommands.ts b/packages/contracts/src/providerCommands.ts new file mode 100644 index 0000000000..090a2205bb --- /dev/null +++ b/packages/contracts/src/providerCommands.ts @@ -0,0 +1,42 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +const ProviderCommandsProviderKind = Schema.Literals(["codex", "claudeAgent"]); + +export const ProviderCommandSource = Schema.Literals(["user", "project", "builtin"]); +export type ProviderCommandSource = typeof ProviderCommandSource.Type; + +export const ProviderCommandEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + path: Schema.optional(TrimmedNonEmptyString), + source: ProviderCommandSource, +}); +export type ProviderCommandEntry = typeof ProviderCommandEntry.Type; + +export const ProviderSkillReference = Schema.Struct({ + name: TrimmedNonEmptyString, + path: TrimmedNonEmptyString, +}); +export type ProviderSkillReference = typeof ProviderSkillReference.Type; + +export const ProviderCommandsListInput = Schema.Struct({ + provider: ProviderCommandsProviderKind, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type ProviderCommandsListInput = typeof ProviderCommandsListInput.Type; + +export const ProviderCommandsListResult = Schema.Struct({ + provider: ProviderCommandsProviderKind, + commands: Schema.Array(ProviderCommandEntry), + skills: Schema.Array(ProviderCommandEntry), +}); +export type ProviderCommandsListResult = typeof ProviderCommandsListResult.Type; + +export class ProviderCommandsListError extends Schema.TaggedErrorClass()( + "ProviderCommandsListError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f47b427bcd..5fc2448417 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -53,6 +53,11 @@ import { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import { + ProviderCommandsListError, + ProviderCommandsListInput, + ProviderCommandsListResult, +} from "./providerCommands"; import { TerminalClearInput, TerminalCloseInput, @@ -80,6 +85,7 @@ export const WS_METHODS = { projectsAdd: "projects.add", projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", + projectsListProviderCommands: "projects.listProviderCommands", projectsWriteFile: "projects.writeFile", // Shell methods @@ -157,6 +163,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: ProjectSearchEntriesError, }); +export const WsProjectsListProviderCommandsRpc = Rpc.make(WS_METHODS.projectsListProviderCommands, { + payload: ProviderCommandsListInput, + success: ProviderCommandsListResult, + error: ProviderCommandsListError, +}); + export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, @@ -349,6 +361,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsProjectsSearchEntriesRpc, + WsProjectsListProviderCommandsRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsSubscribeGitStatusRpc,