diff --git a/apps/server/package.json b/apps/server/package.json index edcb004ded..ba32b5d661 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,6 +35,7 @@ "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "catalog:", diff --git a/apps/server/src/plugins/discovery.ts b/apps/server/src/plugins/discovery.ts new file mode 100644 index 0000000000..020f6d5f2a --- /dev/null +++ b/apps/server/src/plugins/discovery.ts @@ -0,0 +1,148 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { DiscoveredPluginManifest, DiscoveredPluginRoot } from "./types"; + +const PLUGINS_ENV_VAR = "T3CODE_PLUGIN_DIRS"; +const DEFAULT_LOCAL_PLUGINS_DIR = "plugins"; +const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +interface RawPluginManifest { + readonly id?: unknown; + readonly name?: unknown; + readonly version?: unknown; + readonly hostApiVersion?: unknown; + readonly enabled?: unknown; + readonly serverEntry?: unknown; + readonly webEntry?: unknown; +} + +function trimNonEmpty(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function pathExists(candidatePath: string): Promise { + try { + await fs.access(candidatePath); + return true; + } catch { + return false; + } +} + +async function isDirectory(candidatePath: string): Promise { + try { + return (await fs.stat(candidatePath)).isDirectory(); + } catch { + return false; + } +} + +function normalizePluginRoots(cwd: string): string[] { + const configuredRoots = (process.env[PLUGINS_ENV_VAR] ?? "") + .split(path.delimiter) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => path.resolve(value)); + const localRoot = path.resolve(cwd, DEFAULT_LOCAL_PLUGINS_DIR); + return Array.from(new Set([localRoot, ...configuredRoots])); +} + +async function discoverRootCandidates(rootPath: string): Promise { + if (!(await isDirectory(rootPath))) { + return []; + } + + const directManifestPath = path.join(rootPath, PLUGIN_MANIFEST_FILE); + if (await pathExists(directManifestPath)) { + return [{ rootDir: rootPath, manifestPath: directManifestPath }]; + } + + const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []); + const childCandidates = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + rootDir: path.join(rootPath, entry.name), + manifestPath: path.join(rootPath, entry.name, PLUGIN_MANIFEST_FILE), + })); + + const existingCandidates = await Promise.all( + childCandidates.map(async (candidate) => + (await pathExists(candidate.manifestPath)) ? candidate : null, + ), + ); + + return existingCandidates.filter( + (candidate): candidate is DiscoveredPluginRoot => candidate !== null, + ); +} + +export async function discoverPluginRoots(cwd: string): Promise { + const rootCandidates = await Promise.all( + normalizePluginRoots(cwd).map((rootPath) => discoverRootCandidates(rootPath)), + ); + + const flatCandidates = rootCandidates.flat(); + const existingCandidates = await Promise.all( + flatCandidates.map(async (candidate) => + (await pathExists(candidate.manifestPath)) ? candidate : null, + ), + ); + + return existingCandidates.filter( + (candidate): candidate is DiscoveredPluginRoot => candidate !== null, + ); +} + +export async function loadPluginManifest( + root: DiscoveredPluginRoot, +): Promise { + const rawManifest = await fs + .readFile(root.manifestPath, "utf8") + .then((contents) => JSON.parse(contents) as RawPluginManifest) + .catch(() => ({}) as RawPluginManifest); + + const fallbackId = path.basename(root.rootDir); + const id = trimNonEmpty(rawManifest.id) ?? fallbackId; + const name = trimNonEmpty(rawManifest.name) ?? id; + const version = trimNonEmpty(rawManifest.version) ?? "0.0.0"; + const hostApiVersion = trimNonEmpty(rawManifest.hostApiVersion) ?? "unknown"; + const enabled = rawManifest.enabled !== false; + const serverEntry = trimNonEmpty(rawManifest.serverEntry) ?? "dist/server.js"; + const webEntry = trimNonEmpty(rawManifest.webEntry) ?? "dist/web.js"; + const serverEntryPath = (await pathExists(path.resolve(root.rootDir, serverEntry))) + ? path.resolve(root.rootDir, serverEntry) + : null; + const webEntryPath = (await pathExists(path.resolve(root.rootDir, webEntry))) + ? path.resolve(root.rootDir, webEntry) + : null; + + let error: string | null = null; + if (!trimNonEmpty(rawManifest.id)) { + error = "Plugin manifest is missing a valid 'id'."; + } else if (!trimNonEmpty(rawManifest.name)) { + error = "Plugin manifest is missing a valid 'name'."; + } else if (!trimNonEmpty(rawManifest.version)) { + error = "Plugin manifest is missing a valid 'version'."; + } else if (!trimNonEmpty(rawManifest.hostApiVersion)) { + error = "Plugin manifest is missing a valid 'hostApiVersion'."; + } + + return { + id, + name, + version, + hostApiVersion, + enabled, + compatible: hostApiVersion === "1" && error === null, + rootDir: root.rootDir, + manifestPath: root.manifestPath, + serverEntryPath, + webEntryPath, + error, + }; +} diff --git a/apps/server/src/plugins/manager.ts b/apps/server/src/plugins/manager.ts new file mode 100644 index 0000000000..6d71f3e18d --- /dev/null +++ b/apps/server/src/plugins/manager.ts @@ -0,0 +1,420 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { Exit, Schema } from "effect"; + +import type { PluginBootstrap, PluginListItem } from "@t3tools/contracts"; +import type { + ServerPluginContext, + ServerPluginFactory, + ServerPluginProcedure, +} from "@t3tools/plugin-sdk"; +import { formatSchemaError } from "@t3tools/shared/schemaJson"; + +import { createLogger } from "../logger"; +import { listAvailableSkills } from "../skills"; +import { searchWorkspaceEntries } from "../workspaceEntries"; +import { discoverPluginRoots, loadPluginManifest } from "./discovery"; +import type { DiscoveredPluginManifest, LoadedPluginProcedure, LoadedPluginState } from "./types"; + +interface PluginModuleShape { + readonly default?: ServerPluginFactory | undefined; + readonly activateServer?: ServerPluginFactory | undefined; +} + +function resolveServerActivator(module: PluginModuleShape): ServerPluginFactory | null { + if (typeof module.default === "function") { + return module.default; + } + if (typeof module.activateServer === "function") { + return module.activateServer; + } + return null; +} + +export interface PluginManager { + readonly getBootstrap: () => PluginBootstrap; + readonly callProcedure: ( + pluginId: string, + procedureName: string, + payload: unknown, + ) => Promise; + readonly getWebEntry: (pluginId: string) => { filePath: string; version: string } | null; + readonly subscribeToRegistryUpdates: (listener: (ids: string[]) => void) => () => void; + readonly close: () => Promise; +} + +function comparePluginListItems(left: PluginListItem, right: PluginListItem): number { + return left.id.localeCompare(right.id); +} + +function safeMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return String(error); +} + +async function maybeCallCleanup(cleanup: (() => void | Promise) | undefined): Promise { + if (cleanup) { + await cleanup(); + } +} + +function decodeProcedureInput( + pluginId: string, + procedureName: string, + schema: ServerPluginProcedure["input"], + payload: unknown, +): unknown { + const result = Schema.decodeUnknownExit(schema as never)(payload); + if (Exit.isFailure(result)) { + throw new Error( + `Invalid plugin procedure input for '${pluginId}.${procedureName}': ${formatSchemaError(result.cause)}`, + ); + } + return result.value; +} + +function encodeProcedureOutput( + pluginId: string, + procedureName: string, + schema: ServerPluginProcedure["output"], + output: unknown, +): unknown { + const result = Schema.encodeUnknownExit(schema as never)(output); + if (Exit.isFailure(result)) { + throw new Error( + `Invalid plugin procedure output for '${pluginId}.${procedureName}': ${formatSchemaError(result.cause)}`, + ); + } + return result.value; +} + +function pluginListItemFromManifest( + manifest: DiscoveredPluginManifest, + input: { + webVersion: string; + error?: string | null; + }, +): PluginListItem { + const effectiveError = input.error ?? manifest.error; + return { + id: manifest.id, + name: manifest.name, + version: manifest.version, + hostApiVersion: manifest.hostApiVersion, + enabled: manifest.enabled, + compatible: manifest.compatible, + hasServer: manifest.serverEntryPath !== null, + hasWeb: manifest.webEntryPath !== null, + ...(manifest.webEntryPath !== null && + manifest.enabled && + manifest.compatible && + effectiveError === null + ? { + webUrl: `/__plugins/${encodeURIComponent(manifest.id)}/web.js?v=${encodeURIComponent(input.webVersion)}`, + } + : {}), + ...(effectiveError ? { error: effectiveError } : {}), + }; +} + +export async function createPluginManager(input: { cwd: string }): Promise { + const logger = createLogger("plugins"); + const pluginStates = new Map(); + const updateListeners = new Set<(ids: string[]) => void>(); + const watchers: fs.FSWatcher[] = []; + const reloadTimers = new Map>(); + const rootWatchTimers = new Map>(); + const pluginStorageRoot = path.resolve(input.cwd, ".t3", "plugins"); + + const notifyUpdated = (ids: string[]) => { + if (ids.length === 0) { + return; + } + for (const listener of updateListeners) { + try { + listener(ids); + } catch { + // Swallow listener errors. + } + } + }; + + const clearWatchers = () => { + for (const watcher of watchers) { + watcher.close(); + } + watchers.length = 0; + }; + + const unloadPlugin = async (state: LoadedPluginState) => { + const cleanupTasks = [...state.cleanup]; + state.cleanup.length = 0; + state.procedures.clear(); + await Promise.all(cleanupTasks.map((cleanup) => maybeCallCleanup(cleanup))); + }; + + const activatePlugin = async (manifest: DiscoveredPluginManifest): Promise => { + const webVersion = `${Date.now()}`; + const state: LoadedPluginState = { + manifest, + listItem: pluginListItemFromManifest(manifest, { webVersion, error: manifest.error }), + procedures: new Map(), + cleanup: [], + webVersion, + }; + + if (!manifest.enabled) { + return state; + } + + if (!manifest.compatible) { + const error = + manifest.error ?? + `Unsupported hostApiVersion '${manifest.hostApiVersion}' for plugin '${manifest.id}'.`; + logger.warn(`[${manifest.id}] ${error}`); + state.listItem = pluginListItemFromManifest(manifest, { webVersion, error }); + return state; + } + + if (!manifest.serverEntryPath) { + return state; + } + + const pluginLog = { + info: (...args: unknown[]) => + logger.info(`[${manifest.id}] ${args.map(safeMessage).join(" ")}`), + warn: (...args: unknown[]) => + logger.warn(`[${manifest.id}] ${args.map(safeMessage).join(" ")}`), + error: (...args: unknown[]) => + logger.error(`[${manifest.id}] ${args.map(safeMessage).join(" ")}`), + }; + + await fs.promises.mkdir(path.join(pluginStorageRoot, manifest.id), { recursive: true }); + + const context: ServerPluginContext = { + pluginId: manifest.id, + registerProcedure: (procedure) => { + state.procedures.set(procedure.name, { + pluginId: manifest.id, + procedure, + } satisfies LoadedPluginProcedure); + return () => { + state.procedures.delete(procedure.name); + }; + }, + onDispose: (cleanup) => { + state.cleanup.push(cleanup); + }, + host: { + log: pluginLog, + pluginStorageDir: path.join(pluginStorageRoot, manifest.id), + skills: { + list: ({ cwd }) => listAvailableSkills(cwd ? { cwd } : {}), + }, + projects: { + searchEntries: ({ cwd, query, limit }) => + searchWorkspaceEntries({ + cwd, + query: query ?? "", + limit: typeof limit === "number" ? limit : 100, + }), + }, + }, + }; + + try { + const module = (await import( + `${pathToFileURL(manifest.serverEntryPath).href}?v=${Date.now()}` + )) as PluginModuleShape; + const activate = resolveServerActivator(module); + if (activate) { + pluginLog.info("activating"); + const maybeCleanup = await activate(context); + if (typeof maybeCleanup === "function") { + state.cleanup.push(maybeCleanup); + } + } + pluginLog.info("activated"); + } catch (error) { + const message = safeMessage(error); + pluginLog.error("activation failed", message); + await unloadPlugin(state); + state.listItem = pluginListItemFromManifest(manifest, { + webVersion, + error: `Plugin activation failed: ${message}`, + }); + } + + return state; + }; + + const scheduleFullReload = (reasonKey: string) => { + const existing = rootWatchTimers.get(reasonKey); + if (existing) { + clearTimeout(existing); + } + rootWatchTimers.set( + reasonKey, + setTimeout(() => { + rootWatchTimers.delete(reasonKey); + void reloadAllPlugins(); + }, 120), + ); + }; + + const watchTarget = (watchPath: string, onChange: () => void) => { + try { + const watcher = fs.watch(watchPath, () => { + onChange(); + }); + watchers.push(watcher); + } catch (error) { + logger.warn(`failed to watch plugin path '${watchPath}': ${safeMessage(error)}`); + } + }; + + const reloadPlugin = async (pluginId: string) => { + logger.info(`[${pluginId}] reloading`); + await reloadAllPlugins([pluginId]); + }; + + const installWatches = async () => { + clearWatchers(); + + const localAndEnvRoots = Array.from( + new Set([ + path.resolve(input.cwd, "plugins"), + ...(process.env.T3CODE_PLUGIN_DIRS ?? "") + .split(path.delimiter) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => path.resolve(value)), + ]), + ); + + for (const rootPath of localAndEnvRoots) { + watchTarget(rootPath, () => scheduleFullReload(rootPath)); + } + + for (const [pluginId, state] of pluginStates) { + const watchPaths = new Set( + [ + state.manifest.rootDir, + state.manifest.manifestPath, + state.manifest.serverEntryPath, + state.manifest.webEntryPath, + ].flatMap((value) => (value ? [value] : [])), + ); + for (const watchPath of watchPaths) { + watchTarget(watchPath, () => { + const existing = reloadTimers.get(pluginId); + if (existing) { + clearTimeout(existing); + } + reloadTimers.set( + pluginId, + setTimeout(() => { + reloadTimers.delete(pluginId); + void reloadPlugin(pluginId); + }, 120), + ); + }); + } + } + }; + + const reloadAllPlugins = async (preferredIds?: string[]) => { + const previousIds = [...pluginStates.keys()]; + const previousStates = [...pluginStates.values()]; + for (const state of previousStates) { + await unloadPlugin(state); + } + pluginStates.clear(); + + const discoveredRoots = await discoverPluginRoots(input.cwd); + const manifests = await Promise.all(discoveredRoots.map((root) => loadPluginManifest(root))); + for (const manifest of manifests.toSorted((left, right) => left.id.localeCompare(right.id))) { + const state = await activatePlugin(manifest); + pluginStates.set(manifest.id, state); + } + + await installWatches(); + + const nextIds = [...pluginStates.keys()]; + const changedIds = + preferredIds && preferredIds.length > 0 + ? preferredIds + : Array.from(new Set([...previousIds, ...nextIds])).toSorted((left, right) => + left.localeCompare(right), + ); + notifyUpdated(changedIds); + }; + + await reloadAllPlugins(); + + return { + getBootstrap: () => ({ + plugins: [...pluginStates.values()] + .map((state) => state.listItem) + .toSorted(comparePluginListItems), + }), + callProcedure: async (pluginId, procedureName, payload) => { + const pluginState = pluginStates.get(pluginId); + if (!pluginState) { + throw new Error(`Unknown plugin '${pluginId}'.`); + } + const loadedProcedure = pluginState.procedures.get(procedureName); + if (!loadedProcedure) { + throw new Error(`Unknown plugin procedure '${procedureName}' for plugin '${pluginId}'.`); + } + + const decodedInput = decodeProcedureInput( + pluginId, + procedureName, + loadedProcedure.procedure.input, + payload, + ); + const output = await loadedProcedure.procedure.handler(decodedInput); + return encodeProcedureOutput( + pluginId, + procedureName, + loadedProcedure.procedure.output, + output, + ); + }, + getWebEntry: (pluginId) => { + const state = pluginStates.get(pluginId); + if (!state?.manifest.webEntryPath || state.listItem.error) { + return null; + } + return { + filePath: state.manifest.webEntryPath, + version: state.webVersion, + }; + }, + subscribeToRegistryUpdates: (listener) => { + updateListeners.add(listener); + return () => { + updateListeners.delete(listener); + }; + }, + close: async () => { + clearWatchers(); + for (const timer of reloadTimers.values()) { + clearTimeout(timer); + } + reloadTimers.clear(); + for (const timer of rootWatchTimers.values()) { + clearTimeout(timer); + } + rootWatchTimers.clear(); + for (const state of pluginStates.values()) { + await unloadPlugin(state); + } + pluginStates.clear(); + }, + }; +} diff --git a/apps/server/src/plugins/types.ts b/apps/server/src/plugins/types.ts new file mode 100644 index 0000000000..083b7cc60b --- /dev/null +++ b/apps/server/src/plugins/types.ts @@ -0,0 +1,34 @@ +import type { PluginListItem } from "@t3tools/contracts"; +import type { ServerPluginProcedure } from "@t3tools/plugin-sdk"; + +export interface DiscoveredPluginRoot { + readonly rootDir: string; + readonly manifestPath: string; +} + +export interface DiscoveredPluginManifest { + readonly id: string; + readonly name: string; + readonly version: string; + readonly hostApiVersion: string; + readonly enabled: boolean; + readonly compatible: boolean; + readonly rootDir: string; + readonly manifestPath: string; + readonly serverEntryPath: string | null; + readonly webEntryPath: string | null; + readonly error: string | null; +} + +export interface LoadedPluginProcedure { + readonly pluginId: string; + readonly procedure: ServerPluginProcedure; +} + +export interface LoadedPluginState { + readonly manifest: DiscoveredPluginManifest; + listItem: PluginListItem; + readonly procedures: Map; + readonly cleanup: Array<() => void | Promise>; + readonly webVersion: string; +} diff --git a/apps/server/src/prompts.ts b/apps/server/src/prompts.ts new file mode 100644 index 0000000000..f0bf6f2690 --- /dev/null +++ b/apps/server/src/prompts.ts @@ -0,0 +1,143 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { PromptSourceKind, PromptSummary, PromptsListInput } from "@t3tools/contracts"; + +interface PromptRoot { + readonly rootPath: string; + readonly sourceKind: PromptSourceKind; +} + +function sourceOrder(sourceKind: PromptSourceKind): number { + return sourceKind === "project" ? 0 : 1; +} + +function dedupeBy(values: readonly T[], keyOf: (value: T) => string): T[] { + const seen = new Set(); + const next: T[] = []; + for (const value of values) { + const key = keyOf(value); + if (seen.has(key)) continue; + seen.add(key); + next.push(value); + } + return next; +} + +function extractFrontmatter(markdown: string): string { + return /^---\n([\s\S]*?)\n---\n?/.exec(markdown)?.[1] ?? ""; +} + +function extractFrontmatterValue(frontmatter: string, key: string): string | undefined { + const match = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter); + const rawValue = match?.[1]?.trim(); + if (!rawValue) return undefined; + return rawValue.replace(/^['"]|['"]$/g, "").trim() || undefined; +} + +function extractHeading(markdown: string): string | undefined { + const heading = /^#\s+(.+)$/m.exec(markdown)?.[1]?.trim(); + return heading && heading.length > 0 ? heading : undefined; +} + +function parsePromptMarkdown(input: { markdown: string; fallbackName: string }) { + const frontmatter = extractFrontmatter(input.markdown); + const heading = extractHeading(input.markdown); + const displayName = heading ?? input.fallbackName; + const description = + extractFrontmatterValue(frontmatter, "description") ?? + (heading ? `Use the ${heading} prompt.` : `Use the /${input.fallbackName} prompt.`); + const argumentHint = extractFrontmatterValue(frontmatter, "argument-hint"); + + return { + displayName, + description, + argumentHint, + }; +} + +async function listPromptFiles(rootPath: string): Promise { + const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md")) + .map((entry) => path.join(rootPath, entry.name)) + .toSorted((left, right) => left.localeCompare(right)); +} + +async function readPromptSummary(input: { + readonly promptFilePath: string; + readonly sourceKind: PromptSourceKind; +}): Promise { + const markdown = await fs.readFile(input.promptFilePath, "utf8").catch(() => null); + if (!markdown) { + return null; + } + + const fallbackName = path + .basename(input.promptFilePath, path.extname(input.promptFilePath)) + .trim(); + if (!fallbackName) { + return null; + } + + const parsed = parsePromptMarkdown({ + markdown, + fallbackName, + }); + + return { + id: `${input.sourceKind}:${input.promptFilePath}`, + name: fallbackName, + displayName: parsed.displayName, + description: parsed.description, + ...(parsed.argumentHint ? { argumentHint: parsed.argumentHint } : {}), + sourceKind: input.sourceKind, + sourcePath: input.promptFilePath, + defaultPrompt: `/${fallbackName} `, + } satisfies PromptSummary; +} + +function promptRootsForInput(input: PromptsListInput): PromptRoot[] { + const homeDir = os.homedir(); + const projectRoots = + input.cwd?.trim().length && input.cwd + ? [{ rootPath: path.join(input.cwd, ".codex", "prompts"), sourceKind: "project" as const }] + : []; + + const userRoots = [ + { rootPath: path.join(homeDir, ".codex", "prompts"), sourceKind: "user" as const }, + ]; + + return dedupeBy([...projectRoots, ...userRoots], (root) => root.rootPath); +} + +function comparePrompts(left: PromptSummary, right: PromptSummary): number { + const sourceDelta = sourceOrder(left.sourceKind) - sourceOrder(right.sourceKind); + if (sourceDelta !== 0) return sourceDelta; + + const nameDelta = left.name.localeCompare(right.name); + if (nameDelta !== 0) return nameDelta; + + return left.sourcePath.localeCompare(right.sourcePath); +} + +export async function listAvailablePrompts(input: PromptsListInput): Promise { + const roots = promptRootsForInput(input); + const promptSummaries = await Promise.all( + roots.map(async ({ rootPath, sourceKind }) => { + const promptFiles = await listPromptFiles(rootPath); + const prompts = await Promise.all( + promptFiles.map((promptFilePath) => + readPromptSummary({ + promptFilePath, + sourceKind, + }), + ), + ); + return prompts.filter((prompt): prompt is PromptSummary => prompt !== null); + }), + ); + + return dedupeBy(promptSummaries.flat().toSorted(comparePrompts), (prompt) => prompt.name); +} diff --git a/apps/server/src/skills.ts b/apps/server/src/skills.ts new file mode 100644 index 0000000000..3e81e1adfb --- /dev/null +++ b/apps/server/src/skills.ts @@ -0,0 +1,156 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { SkillSummary, SkillsListInput, SkillSourceKind } from "@t3tools/contracts"; + +interface SkillRoot { + readonly rootPath: string; + readonly sourceKind: SkillSourceKind; +} + +function sourceOrder(sourceKind: SkillSourceKind): number { + return sourceKind === "project" ? 0 : sourceKind === "user" ? 1 : 2; +} + +function dedupeBy(values: readonly T[], keyOf: (value: T) => string): T[] { + const seen = new Set(); + const next: T[] = []; + for (const value of values) { + const key = keyOf(value); + if (seen.has(key)) continue; + seen.add(key); + next.push(value); + } + return next; +} + +function titleCaseSkillName(name: string): string { + return name + .split(/[-_]+/g) + .filter((segment) => segment.length > 0) + .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) + .join(" "); +} + +function extractFrontmatterValue(frontmatter: string, key: string): string | undefined { + const match = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter); + const rawValue = match?.[1]?.trim(); + if (!rawValue) return undefined; + return rawValue.replace(/^['"]|['"]$/g, "").trim() || undefined; +} + +function extractHeading(markdown: string): string | undefined { + const match = /^#\s+(.+)$/m.exec(markdown); + const heading = match?.[1]?.trim(); + return heading && heading.length > 0 ? heading : undefined; +} + +function parseSkillMarkdown(input: { markdown: string; fallbackName: string }): { + readonly name: string; + readonly displayName: string; + readonly description: string; +} { + const { markdown, fallbackName } = input; + const frontmatterMatch = /^---\n([\s\S]*?)\n---\n?/.exec(markdown); + const frontmatter = frontmatterMatch?.[1] ?? ""; + + const name = extractFrontmatterValue(frontmatter, "name") ?? fallbackName; + const displayName = extractHeading(markdown) ?? titleCaseSkillName(name); + const description = + extractFrontmatterValue(frontmatter, "description") ?? `Use the ${displayName} skill.`; + + return { + name, + displayName, + description, + }; +} + +async function listSkillDirs(rootPath: string): Promise { + const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(rootPath, entry.name)) + .toSorted((left, right) => left.localeCompare(right)); +} + +async function readSkillSummary(input: { + readonly skillDir: string; + readonly sourceKind: SkillSourceKind; +}): Promise { + const skillFilePath = path.join(input.skillDir, "SKILL.md"); + const markdown = await fs.readFile(skillFilePath, "utf8").catch(() => null); + if (!markdown) { + return null; + } + + const fallbackName = path.basename(input.skillDir).trim(); + if (!fallbackName) { + return null; + } + + const parsed = parseSkillMarkdown({ + markdown, + fallbackName, + }); + + return { + id: `${input.sourceKind}:${input.skillDir}`, + name: parsed.name, + displayName: parsed.displayName, + description: parsed.description, + sourceKind: input.sourceKind, + sourcePath: input.skillDir, + allowImplicitInvocation: true, + defaultPrompt: `$${parsed.name} `, + } satisfies SkillSummary; +} + +function skillRootsForInput(input: SkillsListInput): SkillRoot[] { + const homeDir = os.homedir(); + const projectRoots = + input.cwd?.trim().length && input.cwd + ? [ + { rootPath: path.join(input.cwd, ".codex", "skills"), sourceKind: "project" as const }, + { rootPath: path.join(input.cwd, ".agents", "skills"), sourceKind: "project" as const }, + ] + : []; + + const userRoots = [ + { rootPath: path.join(homeDir, ".codex", "skills"), sourceKind: "user" as const }, + { rootPath: path.join(homeDir, ".agents", "skills"), sourceKind: "user" as const }, + ]; + + return dedupeBy([...projectRoots, ...userRoots], (root) => root.rootPath); +} + +function compareSkills(left: SkillSummary, right: SkillSummary): number { + const sourceDelta = sourceOrder(left.sourceKind) - sourceOrder(right.sourceKind); + if (sourceDelta !== 0) return sourceDelta; + + const nameDelta = left.name.localeCompare(right.name); + if (nameDelta !== 0) return nameDelta; + + return left.sourcePath.localeCompare(right.sourcePath); +} + +export async function listAvailableSkills(input: SkillsListInput): Promise { + const roots = skillRootsForInput(input); + const skillSummaries = await Promise.all( + roots.map(async ({ rootPath, sourceKind }) => { + const skillDirs = await listSkillDirs(rootPath); + const skills = await Promise.all( + skillDirs.map((skillDir) => + readSkillSummary({ + skillDir, + sourceKind, + }), + ), + ); + return skills.filter((skill): skill is SkillSummary => skill !== null); + }), + ); + + return skillSummaries.flat().toSorted(compareSkills); +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e22c23988b..a91024244a 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -78,6 +78,9 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { listAvailableSkills } from "./skills.ts"; +import { listAvailablePrompts } from "./prompts.ts"; +import { createPluginManager } from "./plugins/manager.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -257,6 +260,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const pluginManager = yield* Effect.tryPromise({ + try: () => createPluginManager({ cwd }), + catch: (cause) => new ServerLifecycleError({ operation: "createPluginManager", cause }), + }); + yield* Effect.addFinalizer(() => + Effect.tryPromise({ + try: () => pluginManager.close(), + catch: (cause) => new ServerLifecycleError({ operation: "closePluginManager", cause }), + }).pipe(Effect.ignoreCause({ log: false }), Effect.asVoid), + ); yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( Effect.catch((error) => @@ -289,6 +302,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logOutgoingPush, }); yield* readiness.markPushBusReady; + const unsubscribePluginUpdates = pluginManager.subscribeToRegistryUpdates((ids) => { + void Effect.runPromise( + pushBus.publishAll(WS_CHANNELS.pluginsRegistryUpdated, { + ids, + }), + ); + }); + yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribePluginUpdates())); yield* keybindingsManager.start.pipe( Effect.mapError( (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }), @@ -488,6 +509,36 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } + if (url.pathname.startsWith("/__plugins/")) { + const match = /^\/__plugins\/([^/]+)\/web\.js$/.exec(url.pathname); + const pluginId = match?.[1] ? decodeURIComponent(match[1]) : null; + if (!pluginId) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + const webEntry = pluginManager.getWebEntry(pluginId); + if (!webEntry) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + const data = yield* fileSystem + .readFile(webEntry.filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + respond( + 200, + { + "Content-Type": "text/javascript; charset=utf-8", + "Cache-Control": "no-store", + }, + data, + ); + return; + } + // In dev mode, redirect to Vite dev server if (devUrl) { respond(302, { Location: devUrl.href }); @@ -776,6 +827,47 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { relativePath: target.relativePath }; } + case WS_METHODS.pluginsGetBootstrap: { + return pluginManager.getBootstrap(); + } + + case WS_METHODS.pluginsCallProcedure: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise({ + try: () => pluginManager.callProcedure(body.pluginId, body.procedure, body.payload), + catch: (cause) => + new RouteRequestError({ + message: `Failed to call plugin procedure: ${String(cause)}`, + }), + }); + } + + case WS_METHODS.skillsList: { + const body = stripRequestTag(request.body); + return { + skills: yield* Effect.tryPromise({ + try: () => listAvailableSkills(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to list skills: ${String(cause)}`, + }), + }), + }; + } + + case WS_METHODS.promptsList: { + const body = stripRequestTag(request.body); + return { + prompts: yield* Effect.tryPromise({ + try: () => listAvailablePrompts(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to list prompts: ${String(cause)}`, + }), + }), + }; + } + case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); return yield* openInEditor(body); diff --git a/apps/web/package.json b/apps/web/package.json index f98697fc25..46a1d4a5c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..8229e0d16e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, + type PromptSummary, type ProjectScript, type ModelSlug, type ProviderKind, @@ -13,6 +14,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ServerProviderStatus, + type SkillSummary, type ThreadId, type TurnId, type EditorId, @@ -32,8 +34,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { promptsListQueryOptions } from "~/lib/promptReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; +import { skillsListQueryOptions } from "~/lib/skillReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -83,7 +87,6 @@ import { type ChatMessage, type TurnDiffSummary, } from "../types"; -import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; @@ -178,6 +181,12 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { PluginSlot, usePluginComposerItems } from "../plugins/host"; +import { + buildComposerMenuItems, + resolveSecondaryComposerMenuState, + type SecondaryComposerMenuState, +} from "../plugins/composerBridge"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -188,6 +197,8 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; +const EMPTY_AVAILABLE_PROMPTS: PromptSummary[] = []; +const EMPTY_AVAILABLE_SKILLS: SkillSummary[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function formatOutgoingPrompt(params: { @@ -344,6 +355,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [secondaryComposerMenu, setSecondaryComposerMenu] = + useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< @@ -1023,71 +1036,51 @@ export default function ChatView({ threadId }: ChatViewProps) { limit: 80, }), ); + const promptsQuery = useQuery(promptsListQueryOptions({ cwd: gitCwd })); + const skillsQuery = useQuery( + skillsListQueryOptions({ + cwd: gitCwd, + enabled: composerTriggerKind === "skill-mention" || composerTriggerKind === "slash-skills", + }), + ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const availablePrompts = promptsQuery.data?.prompts ?? EMPTY_AVAILABLE_PROMPTS; + const availableSkills = skillsQuery.data?.skills ?? EMPTY_AVAILABLE_SKILLS; + const pluginComposerItems = usePluginComposerItems({ + triggerKind: + composerTriggerKind === "slash-command" || + composerTriggerKind === "slash-workspace" || + composerTriggerKind === "slash-skills" || + composerTriggerKind === "skill-mention" + ? composerTriggerKind + : null, + query: + composerTriggerKind === "path" || composerTriggerKind === "slash-model" + ? "" + : (composerTrigger?.query ?? ""), + threadId, + cwd: gitCwd, + }); const composerMenuItems = useMemo(() => { - if (!composerTrigger) return []; - if (composerTrigger.kind === "path") { - return workspaceEntries.map((entry) => ({ - id: `path:${entry.kind}:${entry.path}`, - type: "path", - path: entry.path, - pathKind: entry.kind, - label: basenameOfPath(entry.path), - description: entry.parentPath ?? "", - })); - } - - if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ - { - id: "slash:model", - type: "slash-command", - command: "model", - label: "/model", - description: "Switch response model for this thread", - }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal chat mode", - }, - ] satisfies ReadonlyArray>; - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) { - return [...slashCommandItems]; - } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), - ); - } - - return searchableModelOptions - .filter(({ searchSlug, searchName, searchProvider }) => { - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return ( - searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) - ); - }) - .map(({ provider, providerLabel, slug, name }) => ({ - id: `model:${provider}:${slug}`, - type: "model", - provider, - model: slug, - label: name, - description: `${providerLabel} · ${slug}`, - })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); - const composerMenuOpen = Boolean(composerTrigger); + return buildComposerMenuItems({ + composerTrigger, + secondaryComposerMenu, + workspaceEntries, + availablePrompts, + pluginComposerItems: pluginComposerItems.items, + availableSkills, + searchableModelOptions, + }); + }, [ + composerTrigger, + availableSkills, + availablePrompts, + pluginComposerItems.items, + searchableModelOptions, + secondaryComposerMenu, + workspaceEntries, + ]); + const composerMenuOpen = Boolean(composerTrigger || secondaryComposerMenu); const activeComposerMenuItem = useMemo( () => composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? @@ -1833,6 +1826,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { setExpandedWorkGroups({}); setPullRequestDialogState(null); + setSecondaryComposerMenu(null); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); @@ -3235,6 +3229,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; if (item.type === "path") { + setSecondaryComposerMenu(null); const replacement = `@${item.path} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, @@ -3252,9 +3247,66 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.type === "skill") { + setSecondaryComposerMenu(null); + const replacement = item.replacementText; + 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 === "slash-command") { - if (item.command === "model") { - const replacement = "/model "; + if (item.onSelect) { + setComposerHighlightedItemId(null); + void Promise.resolve(item.onSelect()) + .then(async (result) => { + if (!result || result.type === "none") { + return; + } + if (result.type === "open-secondary") { + void resolveSecondaryComposerMenuState({ + title: result.title, + items: result.items, + }).then(setSecondaryComposerMenu); + return; + } + setSecondaryComposerMenu(null); + if (result.type === "replace-trigger") { + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + result.text, + ); + applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, result.text, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + return; + } + applyPromptReplacement(snapshot.expandedCursor, snapshot.expandedCursor, result.text); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Plugin command failed", + description: error instanceof Error ? error.message : "Unknown error", + }); + }); + return; + } + if (item.action === "insert") { + setSecondaryComposerMenu(null); + const replacement = `/${item.command} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, @@ -3271,7 +3323,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + setSecondaryComposerMenu(null); + const nextInteractionMode = item.command === "plan" ? "plan" : "default"; + void handleInteractionModeChange(nextInteractionMode); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); @@ -3280,6 +3334,8 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + + setSecondaryComposerMenu(null); onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -3293,6 +3349,8 @@ export default function ChatView({ threadId }: ChatViewProps) { handleInteractionModeChange, onProviderModelSelect, resolveActiveComposerTrigger, + setComposerHighlightedItemId, + setSecondaryComposerMenu, ], ); const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { @@ -3316,12 +3374,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [composerHighlightedItemId, composerMenuItems], ); + const isComposerPromptLoading = + composerTriggerKind === "slash-command" && (promptsQuery.isLoading || promptsQuery.isFetching); const isComposerMenuLoading = - composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || - workspaceEntriesQuery.isLoading || - workspaceEntriesQuery.isFetching); - + (composerTriggerKind === "path" && + pathTriggerQuery.length > 0 && + composerPathQueryDebouncer.state.isPending) || + (composerTriggerKind === "path" && + (workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching)) || + pluginComposerItems.isLoading || + ((composerTriggerKind === "skill-mention" || composerTriggerKind === "slash-skills") && + (skillsQuery.isLoading || skillsQuery.isFetching)) || + isComposerPromptLoading; const onPromptChange = useCallback( ( nextPrompt: string, @@ -3342,6 +3406,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } promptRef.current = nextPrompt; setPrompt(nextPrompt); + setSecondaryComposerMenu(null); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( threadId, @@ -3731,7 +3796,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "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" + : "Ask anything, @tag files/folders, use / for commands, or $ for skills" } disabled={isConnecting || isComposerApprovalState} /> @@ -4103,6 +4168,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }} /> ) : null} + {/* end horizontal flex container */} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9ff741897c..c824d37e8b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -91,6 +91,7 @@ import { shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { PluginSlot } from "../plugins/host"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -1678,6 +1679,7 @@ export default function Sidebar() { + {isOnSettings ? ( diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ea7f911bec..3423743403 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -5,6 +5,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { memo } from "react"; +import { PluginSlot } from "../../plugins/host"; import GitActionsControl from "../GitActionsControl"; import { DiffIcon } from "lucide-react"; import { Badge } from "../ui/badge"; @@ -94,6 +95,14 @@ export const ChatHeader = memo(function ChatHeader({ /> )} {activeProjectName && } + PluginComposerSelectResult | Promise | undefined; } | { id: string; @@ -30,6 +41,14 @@ export type ComposerCommandItem = model: ModelSlug; label: string; description: string; + } + | { + id: string; + type: "skill"; + label: string; + description: string; + sourceLabel: string; + replacementText: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -65,12 +84,23 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {props.items.length === 0 && (

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

)} + {props.items.length > 0 && ( + item.id === props.activeItemId) ?? props.items[0] ?? null + } + /> + )} ); @@ -104,17 +134,84 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { /> ) : null} {props.item.type === "slash-command" ? ( - + ) : null} {props.item.type === "model" ? ( model ) : null} + {props.item.type === "skill" ? ( + + ) : null} {props.item.label} + {props.item.type === "slash-command" ? ( + + {props.item.badge ?? props.item.action} + + ) : null} + {props.item.type === "skill" ? ( + + skill + + ) : null} + + + {props.item.type === "skill" + ? `${props.item.sourceLabel} · ${props.item.description}` + : props.item.description} - {props.item.description} ); }); + +function SlashCommandIcon(props: { command: string; icon?: PluginComposerIcon }) { + if (props.icon === "file-search" || props.command === "workspace") { + return ; + } + if (props.icon === "sparkles" || props.command === "skills" || props.command === "list-skills") { + return ; + } + if (props.icon === "list" || props.command === "plan") { + return ; + } + if (props.icon === "terminal" || props.command === "default") { + return ; + } + return ; +} + +const ComposerCommandPreview = memo(function ComposerCommandPreview(props: { + item: ComposerCommandItem | null; +}) { + if (!props.item) { + return null; + } + + const detail = + props.item.type === "skill" + ? `${props.item.sourceLabel} skill` + : props.item.type === "slash-command" + ? props.item.badge === "prompt" + ? "Inserts this custom prompt" + : props.item.action === "pick" + ? "Opens a second picker" + : props.item.action === "run" + ? "Runs immediately" + : "Inserts text into the prompt" + : props.item.type === "model" + ? "Switches this thread model" + : "Inserts a workspace path mention"; + + return ( +
+
{props.item.label}
+
{props.item.description}
+
{detail}
+
+ ); +}); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 44f32bef9a..c3720adc7c 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -60,6 +60,48 @@ describe("detectComposerTrigger", () => { }); }); + it("detects workspace picker query after /workspace", () => { + const text = "/workspace src/com"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-workspace", + query: "src/com", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("detects skills picker query after /skills", () => { + const text = "/skills rea"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-skills", + query: "rea", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("detects $skill trigger at token start", () => { + const text = "Use $rea"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "skill-mention", + query: "rea", + rangeStart: "Use ".length, + rangeEnd: text.length, + }); + }); + + it("does not detect $skill trigger in the middle of a word", () => { + const text = "price$rea"; + + expect(detectComposerTrigger(text, text.length)).toBeNull(); + }); + 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..ed9386eded 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,8 +1,20 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; -export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; -export type ComposerSlashCommand = "model" | "plan" | "default"; +export type ComposerTriggerKind = + | "path" + | "slash-command" + | "slash-model" + | "slash-workspace" + | "slash-skills" + | "skill-mention"; +export type ComposerSlashCommand = + | "model" + | "plan" + | "default" + | "workspace" + | "skills" + | "list-skills"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -11,7 +23,6 @@ export interface ComposerTrigger { rangeEnd: number; } -const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; const isInlineTokenSegment = ( segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, ): boolean => segment.type !== "text"; @@ -190,6 +201,26 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const linePrefix = text.slice(lineStart, cursor); if (linePrefix.startsWith("/")) { + const workspaceMatch = /^\/workspace(?:\s+(.*))?$/.exec(linePrefix); + if (workspaceMatch) { + return { + kind: "slash-workspace", + query: (workspaceMatch[1] ?? "").trim(), + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + + const skillsMatch = /^\/skills(?:\s+(.*))?$/.exec(linePrefix); + if (skillsMatch) { + return { + kind: "slash-skills", + query: (skillsMatch[1] ?? "").trim(), + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + const commandMatch = /^\/(\S*)$/.exec(linePrefix); if (commandMatch) { const commandQuery = commandMatch[1] ?? ""; @@ -201,15 +232,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { - return { - kind: "slash-command", - query: commandQuery, - rangeStart: lineStart, - rangeEnd: cursor, - }; - } - return null; + return { + kind: "slash-command", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; } const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); @@ -225,6 +253,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); + if (token.startsWith("$")) { + return { + kind: "skill-mention", + query: token.slice(1), + rangeStart: tokenStart, + rangeEnd: cursor, + }; + } if (!token.startsWith("@")) { return null; } @@ -237,9 +273,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } -export function parseStandaloneComposerSlashCommand( - text: string, -): Exclude | null { +export function parseStandaloneComposerSlashCommand(text: string): "plan" | "default" | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts index 20aa265b87..4f59056579 100644 --- a/apps/web/src/lib/projectReactQuery.ts +++ b/apps/web/src/lib/projectReactQuery.ts @@ -21,6 +21,7 @@ export function projectSearchEntriesQueryOptions(input: { enabled?: boolean; limit?: number; staleTime?: number; + allowEmptyQuery?: boolean; }) { const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; return queryOptions({ @@ -36,7 +37,10 @@ export function projectSearchEntriesQueryOptions(input: { limit, }); }, - enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0, + enabled: + (input.enabled ?? true) && + input.cwd !== null && + (input.allowEmptyQuery === true || input.query.length > 0), staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, }); diff --git a/apps/web/src/lib/promptReactQuery.ts b/apps/web/src/lib/promptReactQuery.ts new file mode 100644 index 0000000000..5c4e800e0f --- /dev/null +++ b/apps/web/src/lib/promptReactQuery.ts @@ -0,0 +1,26 @@ +import { queryOptions } from "@tanstack/react-query"; +import type { PromptsListResult } from "@t3tools/contracts"; + +import { ensureNativeApi } from "../nativeApi"; + +export const promptQueryKeys = { + all: ["prompts"] as const, + list: (cwd: string | null) => ["prompts", "list", cwd] as const, +}; + +const EMPTY_PROMPTS_RESULT: PromptsListResult = { + prompts: [], +}; + +export function promptsListQueryOptions(input: { cwd: string | null; enabled?: boolean }) { + return queryOptions({ + queryKey: promptQueryKeys.list(input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + return api.prompts.list(input.cwd ? { cwd: input.cwd } : {}); + }, + enabled: input.enabled ?? true, + staleTime: 30_000, + placeholderData: (previous) => previous ?? EMPTY_PROMPTS_RESULT, + }); +} diff --git a/apps/web/src/lib/skillReactQuery.ts b/apps/web/src/lib/skillReactQuery.ts new file mode 100644 index 0000000000..d3837b6a3d --- /dev/null +++ b/apps/web/src/lib/skillReactQuery.ts @@ -0,0 +1,26 @@ +import { queryOptions } from "@tanstack/react-query"; +import type { SkillsListResult } from "@t3tools/contracts"; + +import { ensureNativeApi } from "~/nativeApi"; + +export const skillQueryKeys = { + all: ["skills"] as const, + list: (cwd: string | null) => ["skills", "list", cwd] as const, +}; + +const EMPTY_SKILLS_RESULT: SkillsListResult = { + skills: [], +}; + +export function skillsListQueryOptions(input: { cwd: string | null; enabled?: boolean }) { + return queryOptions({ + queryKey: skillQueryKeys.list(input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + return api.skills.list(input.cwd ? { cwd: input.cwd } : {}); + }, + enabled: input.enabled ?? true, + staleTime: 30_000, + placeholderData: (previous) => previous ?? EMPTY_SKILLS_RESULT, + }); +} diff --git a/apps/web/src/plugins/composer.ts b/apps/web/src/plugins/composer.ts new file mode 100644 index 0000000000..b545ca147e --- /dev/null +++ b/apps/web/src/plugins/composer.ts @@ -0,0 +1,13 @@ +import type { PluginComposerItem } from "@t3tools/contracts"; + +export function comparePluginComposerItems( + left: PluginComposerItem, + right: PluginComposerItem, +): number { + const leftPriority = left.priority ?? 0; + const rightPriority = right.priority ?? 0; + if (leftPriority !== rightPriority) { + return rightPriority - leftPriority; + } + return left.label.localeCompare(right.label); +} diff --git a/apps/web/src/plugins/composerBridge.test.ts b/apps/web/src/plugins/composerBridge.test.ts new file mode 100644 index 0000000000..e8bd09bd9b --- /dev/null +++ b/apps/web/src/plugins/composerBridge.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { SkillSummary } from "@t3tools/contracts"; + +import { buildComposerMenuItems, buildSkillComposerItems } from "./composerBridge"; + +const skills: SkillSummary[] = [ + { + id: "user:/skills/react-doctor", + name: "react-doctor", + displayName: "React Doctor", + description: "Diagnose React code health issues.", + sourceKind: "user", + sourcePath: "/skills/react-doctor", + allowImplicitInvocation: true, + defaultPrompt: "$react-doctor ", + }, + { + id: "project:/skills/megaplan", + name: "megaplan", + displayName: "Megaplan", + description: "Build robust plans.", + sourceKind: "project", + sourcePath: "/skills/megaplan", + allowImplicitInvocation: true, + defaultPrompt: "$megaplan ", + }, +]; + +describe("buildSkillComposerItems", () => { + it("sorts project skills before user skills when query is empty", () => { + expect(buildSkillComposerItems({ skills, query: "" })).toMatchObject([ + { label: "Megaplan", replacementText: "$megaplan ", sourceLabel: "Project" }, + { label: "React Doctor", replacementText: "$react-doctor ", sourceLabel: "User" }, + ]); + }); + + it("filters to matching skills for the current query", () => { + expect(buildSkillComposerItems({ skills, query: "react" })).toMatchObject([ + { label: "React Doctor", replacementText: "$react-doctor " }, + ]); + }); +}); + +describe("buildComposerMenuItems", () => { + it("falls back to core skills when no plugin skill provider is available", () => { + const items = buildComposerMenuItems({ + composerTrigger: { + kind: "skill-mention", + query: "mega", + }, + secondaryComposerMenu: null, + workspaceEntries: [], + availablePrompts: [], + pluginComposerItems: [], + availableSkills: skills, + searchableModelOptions: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + type: "skill", + label: "Megaplan", + replacementText: "$megaplan ", + }); + }); +}); diff --git a/apps/web/src/plugins/composerBridge.ts b/apps/web/src/plugins/composerBridge.ts new file mode 100644 index 0000000000..43032394aa --- /dev/null +++ b/apps/web/src/plugins/composerBridge.ts @@ -0,0 +1,316 @@ +import type { + PromptSummary, + ProjectEntry, + ProviderKind, + ModelSlug, + PluginComposerItem, + SkillSummary, +} from "@t3tools/contracts"; + +import type { ComposerTriggerKind } from "~/composer-logic"; +import { basenameOfPath } from "~/vscode-icons"; +import type { ComposerCommandItem } from "~/components/chat/ComposerCommandMenu"; + +export interface SecondaryComposerMenuState { + readonly title: string; + readonly items: readonly ComposerCommandItem[]; +} + +interface SearchableModelOption { + readonly provider: ProviderKind; + readonly providerLabel: string; + readonly slug: ModelSlug; + readonly name: string; + readonly searchSlug: string; + readonly searchName: string; + readonly searchProvider: string; +} + +function normalizeComposerSearchValue(value: string): string { + return value.trim().toLowerCase(); +} + +function scoreSlashCommandMatch( + item: Extract, + rawQuery: string, +): number | null { + const query = normalizeComposerSearchValue(rawQuery); + if (!query) { + return item.action === "pick" ? 0 : item.action === "run" ? 1 : 2; + } + + const command = item.command.toLowerCase(); + const label = item.label.toLowerCase(); + const description = item.description.toLowerCase(); + const keywords = item.keywords?.map((keyword) => keyword.toLowerCase()) ?? []; + + if (command === query) return 0; + if (command.startsWith(query)) return 1; + if (label.startsWith(query)) return 2; + if (keywords.some((keyword) => keyword.startsWith(query))) return 3; + if (label.includes(query)) return 4; + if (keywords.some((keyword) => keyword.includes(query))) return 5; + if (description.includes(query)) return 6; + return null; +} + +export function mapPluginComposerItem(item: PluginComposerItem): ComposerCommandItem { + if (item.type === "path") { + return { + id: item.id, + type: "path", + path: item.path, + pathKind: item.pathKind, + label: item.label, + description: item.description, + }; + } + + if (item.type === "skill") { + return { + id: item.id, + type: "skill", + label: item.label, + description: item.description, + sourceLabel: item.sourceLabel, + replacementText: item.replacementText, + }; + } + + return { + id: item.id, + type: "slash-command", + command: item.id, + action: item.action, + label: item.label, + description: item.description, + keywords: item.keywords ? [...item.keywords] : undefined, + icon: item.icon, + badge: item.badge, + onSelect: item.onSelect, + }; +} + +function normalizeSkillSearchValue(value: string): string { + return value.trim().toLowerCase(); +} + +function scoreSkillMatch( + skill: Pick, + rawQuery: string, +): number | null { + const query = normalizeSkillSearchValue(rawQuery); + if (!query) { + return 0; + } + + const name = skill.name.toLowerCase(); + const displayName = skill.displayName.toLowerCase(); + const description = skill.description.toLowerCase(); + + if (name === query) return 0; + if (displayName === query) return 1; + if (name.startsWith(query)) return 2; + if (displayName.startsWith(query)) return 3; + if (name.includes(query)) return 4; + if (displayName.includes(query)) return 5; + if (description.includes(query)) return 6; + return null; +} + +function sourceRank(sourceKind: SkillSummary["sourceKind"]): number { + return sourceKind === "project" ? 0 : sourceKind === "user" ? 1 : 2; +} + +function buildSkillSourceLabel(sourceKind: SkillSummary["sourceKind"]): string { + return sourceKind === "project" ? "Project" : sourceKind === "user" ? "User" : "System"; +} + +export function buildSkillComposerItems(input: { + skills: readonly SkillSummary[]; + query: string; +}): ComposerCommandItem[] { + return input.skills + .filter((skill) => scoreSkillMatch(skill, input.query) !== null) + .toSorted( + (left, right) => + (scoreSkillMatch(left, input.query) ?? Number.MAX_SAFE_INTEGER) - + (scoreSkillMatch(right, input.query) ?? Number.MAX_SAFE_INTEGER) || + sourceRank(left.sourceKind) - sourceRank(right.sourceKind) || + left.name.localeCompare(right.name), + ) + .map((skill) => ({ + id: `builtin-skill:${skill.id}`, + type: "skill", + label: skill.displayName, + description: skill.description, + sourceLabel: buildSkillSourceLabel(skill.sourceKind), + replacementText: skill.defaultPrompt, + })); +} + +export function buildComposerMenuItems(input: { + composerTrigger: { + kind: ComposerTriggerKind; + query: string; + } | null; + secondaryComposerMenu: SecondaryComposerMenuState | null; + workspaceEntries: readonly ProjectEntry[]; + availablePrompts: readonly PromptSummary[]; + pluginComposerItems: readonly PluginComposerItem[]; + availableSkills: readonly SkillSummary[]; + searchableModelOptions: readonly SearchableModelOption[]; +}): ComposerCommandItem[] { + if (input.secondaryComposerMenu) { + return [...input.secondaryComposerMenu.items]; + } + if (!input.composerTrigger) { + return []; + } + + if (input.composerTrigger.kind === "path") { + return input.workspaceEntries.map((entry) => ({ + id: `path:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOfPath(entry.path), + description: entry.parentPath ?? "", + })); + } + + if ( + input.composerTrigger.kind === "skill-mention" || + input.composerTrigger.kind === "slash-skills" || + input.composerTrigger.kind === "slash-workspace" + ) { + const pluginItems = input.pluginComposerItems.map(mapPluginComposerItem); + if ( + input.composerTrigger.kind === "skill-mention" || + input.composerTrigger.kind === "slash-skills" + ) { + const builtinSkillItems = buildSkillComposerItems({ + skills: input.availableSkills, + query: input.composerTrigger.query, + }); + const pluginSkillKeys = new Set( + pluginItems + .filter( + (item): item is Extract => + item.type === "skill", + ) + .map((item) => `${item.replacementText}\n${item.label}\n${item.sourceLabel}`), + ); + return [ + ...pluginItems, + ...builtinSkillItems.filter( + (item) => + item.type !== "skill" || + !pluginSkillKeys.has(`${item.replacementText}\n${item.label}\n${item.sourceLabel}`), + ), + ]; + } + return pluginItems; + } + + if (input.composerTrigger.kind === "slash-command") { + const slashQuery = input.composerTrigger.query; + const slashCommandItems = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + action: "insert", + label: "Switch model", + description: "Insert /model to choose a different thread model", + keywords: ["model", "switch", "provider"], + }, + { + id: "slash:plan", + type: "slash-command", + command: "plan", + action: "run", + label: "Insert plan request", + description: "Switch this thread into plan mode", + keywords: ["plan", "mode"], + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + action: "run", + label: "Return to default mode", + description: "Switch this thread back to normal chat mode", + keywords: ["default", "chat", "mode"], + }, + ] satisfies ReadonlyArray>; + + const builtInItems = [...slashCommandItems] + .filter((item) => scoreSlashCommandMatch(item, slashQuery) !== null) + .toSorted( + (left, right) => + (scoreSlashCommandMatch(left, slashQuery) ?? Number.MAX_SAFE_INTEGER) - + (scoreSlashCommandMatch(right, slashQuery) ?? Number.MAX_SAFE_INTEGER) || + left.label.localeCompare(right.label), + ); + + const promptItems = input.availablePrompts + .map( + (prompt) => + ({ + id: `prompt:${prompt.sourceKind}:${prompt.name}`, + type: "slash-command", + command: prompt.name, + action: "insert", + label: `/${prompt.name}`, + description: prompt.description, + keywords: [ + prompt.name, + prompt.displayName, + ...(prompt.argumentHint ? [prompt.argumentHint] : []), + ], + badge: "prompt", + }) satisfies Extract, + ) + .filter((item) => scoreSlashCommandMatch(item, slashQuery) !== null) + .toSorted( + (left, right) => + (scoreSlashCommandMatch(left, slashQuery) ?? Number.MAX_SAFE_INTEGER) - + (scoreSlashCommandMatch(right, slashQuery) ?? Number.MAX_SAFE_INTEGER) || + left.label.localeCompare(right.label), + ); + + const pluginItems = input.pluginComposerItems.flatMap((item) => + item.type === "slash-command" ? [mapPluginComposerItem(item)] : [], + ); + + return [...builtInItems, ...promptItems, ...pluginItems]; + } + + return input.searchableModelOptions + .filter(({ searchSlug, searchName, searchProvider }) => { + const query = input.composerTrigger?.query.trim().toLowerCase() ?? ""; + if (!query) return true; + return ( + searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) + ); + }) + .map(({ provider, providerLabel, slug, name }) => ({ + id: `model:${provider}:${slug}`, + type: "model", + provider, + model: slug, + label: name, + description: `${providerLabel} · ${slug}`, + })); +} + +export async function resolveSecondaryComposerMenuState(input: { + title: string; + items: readonly PluginComposerItem[] | Promise; +}): Promise { + return { + title: input.title, + items: (await Promise.resolve(input.items)).map(mapPluginComposerItem), + }; +} diff --git a/apps/web/src/plugins/host.tsx b/apps/web/src/plugins/host.tsx new file mode 100644 index 0000000000..929af5b111 --- /dev/null +++ b/apps/web/src/plugins/host.tsx @@ -0,0 +1,338 @@ +import * as React from "react"; +import { + type PropsWithChildren, + type ReactNode, + Component, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import type { + PluginComposerItem, + PluginComposerQueryContext, + PluginSlotId, +} from "@t3tools/contracts"; + +import { ensureNativeApi } from "~/nativeApi"; +import { comparePluginComposerItems } from "./composer"; +import { resolveWebPluginActivator } from "./runtime"; + +interface LoadedWebPluginHandle { + readonly cleanup: (() => void | Promise) | null; +} + +type SlotRenderer = (props: Record) => ReactNode; + +interface RegisteredSlotRenderer { + readonly id: string; + readonly pluginId: string; + readonly renderer: SlotRenderer; +} + +interface PluginComposerProvider { + readonly id: string; + readonly pluginId: string; + readonly triggers: readonly PluginComposerQueryContext["triggerKind"][]; + readonly getItems: ( + input: PluginComposerQueryContext, + ) => readonly PluginComposerItem[] | Promise; +} + +interface PluginHostContextValue { + readonly revision: number; + readonly getComposerProviders: ( + triggerKind: PluginComposerQueryContext["triggerKind"], + ) => readonly PluginComposerProvider[]; + readonly getSlotRenderers: (slotId: PluginSlotId) => readonly RegisteredSlotRenderer[]; +} + +const PluginHostContext = createContext(null); + +class PluginRenderErrorBoundary extends Component< + PropsWithChildren<{ pluginId: string }>, + { hasError: boolean } +> { + override state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + override componentDidCatch(error: unknown) { + console.warn(`Plugin render failed '${this.props.pluginId}'`, error); + } + + override render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + +export function PluginHostProvider(props: PropsWithChildren) { + const nativeApi = useMemo(() => ensureNativeApi(), []); + const composerProvidersRef = useRef([]); + const slotRenderersRef = useRef>(new Map()); + const loadedPluginsRef = useRef>(new Map()); + const [revision, setRevision] = useState(0); + + const bumpRevision = useCallback(() => { + setRevision((current) => current + 1); + }, []); + + const unregisterPlugin = useCallback( + async (pluginId: string) => { + composerProvidersRef.current = composerProvidersRef.current.filter( + (provider) => provider.pluginId !== pluginId, + ); + for (const [slotId, renderers] of slotRenderersRef.current) { + const nextRenderers = renderers.filter((renderer) => renderer.pluginId !== pluginId); + if (nextRenderers.length === 0) { + slotRenderersRef.current.delete(slotId); + } else { + slotRenderersRef.current.set(slotId, nextRenderers); + } + } + + const handle = loadedPluginsRef.current.get(pluginId); + loadedPluginsRef.current.delete(pluginId); + if (handle?.cleanup) { + await handle.cleanup(); + } + bumpRevision(); + }, + [bumpRevision], + ); + + useEffect(() => { + let cancelled = false; + const loadedPluginsRefValue = loadedPluginsRef; + + const loadPlugins = async () => { + const bootstrap = await nativeApi.plugins.getBootstrap(); + if (cancelled) { + return; + } + + await Promise.all( + [...loadedPluginsRef.current.keys()].map((pluginId) => unregisterPlugin(pluginId)), + ); + + for (const plugin of bootstrap.plugins) { + if (!plugin.webUrl || !plugin.enabled || !plugin.compatible) { + continue; + } + + try { + const module = await import(/* @vite-ignore */ plugin.webUrl); + if (cancelled) { + return; + } + + const activate = resolveWebPluginActivator(module); + if (!activate) { + continue; + } + + const pluginId = plugin.id; + let slotRegistrationCount = 0; + const cleanupHandles: Array<() => void> = []; + const cleanupRegistrations = () => { + for (const cleanup of cleanupHandles.toReversed()) { + cleanup(); + } + }; + + const cleanup = await activate({ + pluginId, + callProcedure: (input: { pluginId?: string; procedure: string; payload?: unknown }) => + nativeApi.plugins.callProcedure({ + pluginId: input.pluginId ?? pluginId, + procedure: input.procedure, + ...(input.payload !== undefined ? { payload: input.payload } : {}), + }), + registerComposerProvider: (provider: Omit) => { + const registeredProvider: PluginComposerProvider = { + ...provider, + pluginId, + id: `${pluginId}:${provider.id}`, + }; + composerProvidersRef.current = [...composerProvidersRef.current, registeredProvider]; + bumpRevision(); + const unregister = () => { + composerProvidersRef.current = composerProvidersRef.current.filter( + (candidate) => candidate.id !== registeredProvider.id, + ); + bumpRevision(); + }; + cleanupHandles.push(unregister); + return unregister; + }, + registerSlot: (slotId: PluginSlotId, renderer: SlotRenderer) => { + slotRegistrationCount += 1; + const rendererId = `${pluginId}:${slotId}:${slotRegistrationCount}`; + const current = slotRenderersRef.current.get(slotId) ?? []; + slotRenderersRef.current.set(slotId, [ + ...current, + { id: rendererId, pluginId, renderer }, + ]); + bumpRevision(); + const unregister = () => { + const existing = slotRenderersRef.current.get(slotId) ?? []; + const next = existing.filter((candidate) => candidate.id !== rendererId); + if (next.length === 0) { + slotRenderersRef.current.delete(slotId); + } else { + slotRenderersRef.current.set(slotId, next); + } + bumpRevision(); + }; + cleanupHandles.push(unregister); + return unregister; + }, + onDispose: (cleanup: () => void | Promise) => { + cleanupHandles.push(() => { + void cleanup(); + }); + }, + }); + + loadedPluginsRef.current.set(pluginId, { + cleanup: async () => { + cleanupRegistrations(); + if (typeof cleanup === "function") { + await cleanup(); + } + }, + }); + bumpRevision(); + } catch (error) { + console.warn(`Failed to load web plugin '${plugin.id}'`, error); + } + } + }; + + void loadPlugins(); + const unsubscribe = nativeApi.plugins.onRegistryUpdated(() => { + void loadPlugins(); + }); + + return () => { + cancelled = true; + unsubscribe(); + void Promise.all( + [...loadedPluginsRefValue.current.keys()].map((pluginId) => unregisterPlugin(pluginId)), + ); + }; + }, [bumpRevision, nativeApi, unregisterPlugin]); + + const contextValue = useMemo( + () => ({ + revision, + getComposerProviders: (triggerKind) => + composerProvidersRef.current + .filter((provider) => provider.triggers.includes(triggerKind)) + .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)), + getSlotRenderers: (slotId) => + (slotRenderersRef.current.get(slotId) ?? []).toSorted((left, right) => + left.pluginId.localeCompare(right.pluginId), + ), + }), + [revision], + ); + + return ( + {props.children} + ); +} + +function usePluginHostContext(): PluginHostContextValue { + const value = useContext(PluginHostContext); + if (!value) { + throw new Error("PluginHostProvider is missing"); + } + return value; +} + +export function usePluginComposerItems(input: { + triggerKind: PluginComposerQueryContext["triggerKind"] | null; + query: string; + threadId?: string; + cwd?: string | null; +}) { + const host = usePluginHostContext(); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!input.triggerKind) { + setItems([]); + setIsLoading(false); + return; + } + + const matchingProviders = host.getComposerProviders(input.triggerKind); + if (matchingProviders.length === 0) { + setItems([]); + setIsLoading(false); + return; + } + + let cancelled = false; + setIsLoading(true); + void Promise.all( + matchingProviders.map(async (provider) => { + try { + return await provider.getItems({ + triggerKind: input.triggerKind!, + query: input.query, + ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + }); + } catch (error) { + console.warn(`Failed to resolve plugin composer provider '${provider.id}'`, error); + return []; + } + }), + ).then((results) => { + if (cancelled) { + return; + } + setItems(results.flat().toSorted(comparePluginComposerItems)); + setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [host, host.revision, input.cwd, input.query, input.threadId, input.triggerKind]); + + return { + items, + isLoading, + }; +} + +export function PluginSlot(props: { slotId: PluginSlotId; renderProps?: Record }) { + const host = usePluginHostContext(); + const renderers = host.getSlotRenderers(props.slotId); + if (renderers.length === 0) { + return null; + } + + return ( + <> + {renderers.map((renderer) => ( + + {renderer.renderer(props.renderProps ?? {})} + + ))} + + ); +} diff --git a/apps/web/src/plugins/runtime.ts b/apps/web/src/plugins/runtime.ts new file mode 100644 index 0000000000..36cc387caa --- /dev/null +++ b/apps/web/src/plugins/runtime.ts @@ -0,0 +1,16 @@ +export interface WebPluginModuleShape { + readonly default?: ((ctx: unknown) => unknown) | undefined; + readonly activateWeb?: ((ctx: unknown) => unknown) | undefined; +} + +export function resolveWebPluginActivator( + module: WebPluginModuleShape, +): ((ctx: unknown) => unknown) | null { + if (typeof module.default === "function") { + return module.default; + } + if (typeof module.activateWeb === "function") { + return module.activateWeb; + } + return null; +} diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 0192ee0c6c..141f6c37ba 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -2,6 +2,7 @@ import { createElement } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter } from "@tanstack/react-router"; +import { PluginHostProvider } from "./plugins/host"; import { routeTree } from "./routeTree.gen"; import { StoreProvider } from "./store"; @@ -20,7 +21,7 @@ export function getRouter(history: RouterHistory) { createElement( QueryClientProvider, { client: queryClient }, - createElement(StoreProvider, null, children), + createElement(PluginHostProvider, null, createElement(StoreProvider, null, children)), ), }); } diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde69..bb0c71feb9 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -87,7 +87,6 @@ export function createWsNativeApi(): NativeApi { } } }); - const api: NativeApi = { dialogs: { pickFolder: async () => { @@ -115,6 +114,24 @@ export function createWsNativeApi(): NativeApi { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), }, + plugins: { + getBootstrap: () => transport.request(WS_METHODS.pluginsGetBootstrap), + callProcedure: (input) => transport.request(WS_METHODS.pluginsCallProcedure, input), + onRegistryUpdated: (callback) => + transport.subscribe( + WS_CHANNELS.pluginsRegistryUpdated, + (message) => callback(message.data), + { + replayLatest: true, + }, + ), + }, + skills: { + list: (input) => transport.request(WS_METHODS.skillsList, input), + }, + prompts: { + list: (input) => transport.request(WS_METHODS.promptsList, input), + }, shell: { openInEditor: (cwd, editor) => transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 56b138d331..7a8625a5c4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -8,6 +8,21 @@ import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); +function resolvePluginProxyTarget(wsUrl: string | undefined): string | undefined { + const trimmedUrl = wsUrl?.trim(); + if (!trimmedUrl) { + return undefined; + } + + try { + const parsed = new URL(trimmedUrl); + const protocol = parsed.protocol === "wss:" ? "https:" : "http:"; + return `${protocol}//${parsed.host}`; + } catch { + return undefined; + } +} + const buildSourcemap = sourcemapEnv === "0" || sourcemapEnv === "false" ? false @@ -15,6 +30,8 @@ const buildSourcemap = ? "hidden" : true; +const pluginProxyTarget = resolvePluginProxyTarget(process.env.VITE_WS_URL); + export default defineConfig({ plugins: [ tanstackRouter(), @@ -50,6 +67,16 @@ export default defineConfig({ protocol: "ws", host: "localhost", }, + ...(pluginProxyTarget + ? { + proxy: { + "/__plugins": { + target: pluginProxyTarget, + changeOrigin: true, + }, + }, + } + : {}), }, build: { outDir: "dist", diff --git a/bun.lock b/bun.lock index c20107dc89..1e791686b3 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "catalog:", @@ -83,6 +84,7 @@ "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", @@ -135,6 +137,18 @@ "vitest": "catalog:", }, }, + "packages/plugin-sdk": { + "name": "@t3tools/plugin-sdk", + "version": "0.0.1", + "dependencies": { + "@t3tools/contracts": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + }, + }, "packages/shared": { "name": "@t3tools/shared", "version": "0.0.0-alpha.1", @@ -666,6 +680,8 @@ "@t3tools/marketing": ["@t3tools/marketing@workspace:apps/marketing"], + "@t3tools/plugin-sdk": ["@t3tools/plugin-sdk@workspace:packages/plugin-sdk"], + "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"], "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"], @@ -832,6 +848,7 @@ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], diff --git a/docs/plans/plan-001-plugin-host-redesign.md b/docs/plans/plan-001-plugin-host-redesign.md new file mode 100644 index 0000000000..911f8051f1 --- /dev/null +++ b/docs/plans/plan-001-plugin-host-redesign.md @@ -0,0 +1,34 @@ +# Plan 001: Plugin Host Redesign + +This execution plan lands a clean-break `plugins` architecture that replaces the current extension proof of concept end to end. + +Scope: + +- add plugin contracts in `packages/contracts/src/plugin.ts` +- add `@t3tools/plugin-sdk` +- add server runtime under `apps/server/src/plugins/*` +- add web runtime under `apps/web/src/plugins/*` +- migrate `codex-composer` into `plugins/codex-composer/*` +- remove legacy extension runtime paths after migration +- add plugin host docs and reapply guidance + +V1 includes: + +- manifest-driven discovery with `t3-plugin.json` +- `registerProcedure`, `registerComposerProvider`, and `registerSlot` +- slots: + - `chat.header.actions.after` + - `sidebar.footer.before` + - `thread.rightPanel.tabs` +- host helpers: + - `ctx.host.skills.list` + - `ctx.host.projects.searchEntries` + - `ctx.host.log` + - `ctx.host.pluginStorageDir` + +V1 excludes: + +- override surfaces +- generic host internals +- persistent external plugin-root config files +- extra capabilities beyond the current `codex-composer` seam diff --git a/docs/plugins/migration-from-poc.md b/docs/plugins/migration-from-poc.md new file mode 100644 index 0000000000..c508399b1f --- /dev/null +++ b/docs/plugins/migration-from-poc.md @@ -0,0 +1,46 @@ +# Migration From POC + +This migration is a clean break from `extensions` to `plugins`. + +## Rename Surface + +Replaced: + +- `extensions.list` -> `plugins.getBootstrap` +- `extensions.call` -> `plugins.callProcedure` +- `extensions.updated` -> `plugins.registryUpdated` +- `t3.extension.json` -> `t3-plugin.json` +- `extensions/codex-composer/*` -> `plugins/codex-composer/*` + +Removed after migration: + +- `apps/server/src/extensions/*` +- `apps/web/src/extensions/*` +- `extensions/codex-composer/*` + +## Runtime Shape + +Server host helpers in v1: + +- `ctx.host.skills.list` +- `ctx.host.projects.searchEntries` +- `ctx.host.log` +- `ctx.host.pluginStorageDir` + +Web host helpers in v1: + +- `callProcedure` +- `registerComposerProvider` +- `registerSlot` + +## Reference Plugin + +`codex-composer` now owns: + +- `/` menu contributions for skills and workspace pickers +- `$` skill suggestions +- secondary picker flows +- server-backed `skills.list` +- server-backed workspace search + +The plugin package source is authoritative. `dist/*` is loadable runtime output. diff --git a/docs/plugins/plugin-host-spec.md b/docs/plugins/plugin-host-spec.md new file mode 100644 index 0000000000..3d087ede68 --- /dev/null +++ b/docs/plugins/plugin-host-spec.md @@ -0,0 +1,135 @@ +# Plugin Host Spec + +## Boundary + +Public terminology is `plugin`. + +The host exposes: + +- transport: + - `plugins.getBootstrap` + - `plugins.callProcedure` + - `plugins.registryUpdated` +- manifest file: + - `t3-plugin.json` +- SDK: + - `@t3tools/plugin-sdk` +- shared schemas/types: + - `@t3tools/contracts` + +Plugin code may import only `@t3tools/plugin-sdk` and `@t3tools/contracts`. + +## Manifest + +Required fields: + +- `id` +- `name` +- `version` +- `hostApiVersion` + +Optional fields: + +- `enabled` default `true` +- `serverEntry` default `dist/server.js` +- `webEntry` default `dist/web.js` + +Compatibility: + +- only `hostApiVersion: "1"` is active +- incompatible plugins remain visible in bootstrap diagnostics and are disabled + +## Discovery + +Discovery order: + +1. repo-local `plugins/` +2. directories from `T3CODE_PLUGIN_DIRS` + +Rules: + +- each configured root may itself be a plugin root or a container of child plugin roots +- only directories with `t3-plugin.json` count as plugin roots +- there is no implicit `dist/*` discovery + +## Server Runtime + +Host files touched: + +- `apps/server/src/plugins/discovery.ts` +- `apps/server/src/plugins/manager.ts` +- `apps/server/src/plugins/types.ts` +- `apps/server/src/wsServer.ts` + +Responsibilities: + +- discover and validate manifests +- reject incompatible `hostApiVersion` +- activate server plugins +- register typed procedures +- reload on manifest or entry changes +- unload procedures and cleanup handlers before reload +- serve web bundles at `/__plugins/:pluginId/web.js?v=:version` +- publish `plugins.registryUpdated` + +Procedure guarantees: + +- missing procedure is a deterministic request error +- input decode failure is a deterministic request error +- output encode failure is a deterministic request error +- activation failure disables only that plugin + +## Web Runtime + +Host files touched: + +- `apps/web/src/plugins/runtime.ts` +- `apps/web/src/plugins/host.tsx` +- `apps/web/src/plugins/composer.ts` +- `apps/web/src/plugins/composerBridge.ts` +- `apps/web/src/router.ts` +- `apps/web/src/wsNativeApi.ts` +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/components/chat/ChatHeader.tsx` +- `apps/web/src/components/Sidebar.tsx` + +Responsibilities: + +- fetch plugin bootstrap +- dynamically import enabled compatible web bundles +- register/unregister composer providers +- register/unregister slot renderers +- reload on `plugins.registryUpdated` +- isolate plugin render failures with error boundaries + +Web boundary rules: + +- plugin code gets `callProcedure`, `registerComposerProvider`, and `registerSlot` +- no raw React, native API, query client, stores, or host components are exposed +- slot rendering order is deterministic by `pluginId` +- reload rebuilds registry state from scratch + +## Composer Bridge + +`apps/web/src/plugins/composerBridge.ts` owns plugin-related composer merging: + +- built-in slash commands +- prompt slash commands +- plugin slash commands +- plugin skill results +- plugin workspace picker results +- secondary picker state shaping + +`ChatView` should not understand plugin item internals directly beyond invoking mapped `ComposerCommandItem`s. + +## V1 Exclusions + +Not part of this issue: + +- `registerOverride` +- extra slot ids +- raw host stores/components +- git internals +- generic write APIs +- `~/.t3/plugins.json` +- new capabilities beyond current `codex-composer` replacement diff --git a/docs/plugins/plugin-reapply-prompt.md b/docs/plugins/plugin-reapply-prompt.md new file mode 100644 index 0000000000..160c414c14 --- /dev/null +++ b/docs/plugins/plugin-reapply-prompt.md @@ -0,0 +1,28 @@ +# Plugin Reapply Prompt + +Reapply the plugin-host seam onto upstream with the following invariants: + +1. Keep terminology as `plugin`, not `extension`. +2. Preserve transport names exactly: + - `plugins.getBootstrap` + - `plugins.callProcedure` + - `plugins.registryUpdated` +3. Preserve manifest file name `t3-plugin.json`. +4. Preserve slot ids exactly: + - `chat.header.actions.after` + - `sidebar.footer.before` + - `thread.rightPanel.tabs` +5. Preserve v1 host helpers exactly: + - `ctx.host.skills.list` + - `ctx.host.projects.searchEntries` + - `ctx.host.log` + - `ctx.host.pluginStorageDir` +6. Do not reintroduce: + - override surfaces + - raw React/nativeApi/queryClient exposure + - external plugin-root config files + - extra slot ids +7. Keep composer/plugin merge logic isolated to `apps/web/src/plugins/composerBridge.ts`. +8. Keep server discovery/activation isolated to `apps/server/src/plugins/*`. +9. Keep reference plugin behavior in `plugins/codex-composer/*`, not host UI files. +10. Keep registry ordering deterministic by `pluginId`. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..f73c489c9d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,3 +11,6 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./skill"; +export * from "./prompt"; +export * from "./plugin"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..22002eae34 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -24,6 +24,13 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import type { + PluginBootstrap, + PluginProcedureCallInput, + PluginRegistryUpdatedPayload, +} from "./plugin"; +import type { SkillsListInput, SkillsListResult } from "./skill"; +import type { PromptsListInput, PromptsListResult } from "./prompt"; import type { ServerConfig } from "./server"; import type { TerminalClearInput, @@ -129,6 +136,17 @@ export interface NativeApi { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; + plugins: { + getBootstrap: () => Promise; + callProcedure: (input: PluginProcedureCallInput) => Promise; + onRegistryUpdated: (callback: (payload: PluginRegistryUpdatedPayload) => void) => () => void; + }; + skills: { + list: (input: SkillsListInput) => Promise; + }; + prompts: { + list: (input: PromptsListInput) => Promise; + }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; openExternal: (url: string) => Promise; diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts new file mode 100644 index 0000000000..bcc7fd5df3 --- /dev/null +++ b/packages/contracts/src/plugin.ts @@ -0,0 +1,121 @@ +import { Schema } from "effect"; + +import type { ProjectEntry } from "./project"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const PluginSlotId = Schema.Literals([ + "chat.header.actions.after", + "sidebar.footer.before", + "thread.rightPanel.tabs", +]); +export type PluginSlotId = typeof PluginSlotId.Type; + +export const PluginComposerTriggerKind = Schema.Literals([ + "slash-command", + "slash-workspace", + "slash-skills", + "skill-mention", +]); +export type PluginComposerTriggerKind = typeof PluginComposerTriggerKind.Type; + +export type PluginComposerIcon = "bot" | "file-search" | "list" | "sparkles" | "terminal"; + +export const PluginManifest = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + version: TrimmedNonEmptyString, + hostApiVersion: Schema.Literal("1"), + enabled: Schema.optional(Schema.Boolean), + serverEntry: Schema.optional(TrimmedNonEmptyString), + webEntry: Schema.optional(TrimmedNonEmptyString), +}); +export type PluginManifest = typeof PluginManifest.Type; + +export const PluginListItem = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + version: TrimmedNonEmptyString, + hostApiVersion: TrimmedNonEmptyString, + enabled: Schema.Boolean, + compatible: Schema.Boolean, + hasServer: Schema.Boolean, + hasWeb: Schema.Boolean, + webUrl: Schema.optional(TrimmedNonEmptyString), + error: Schema.optional(TrimmedNonEmptyString), +}); +export type PluginListItem = typeof PluginListItem.Type; + +export const PluginBootstrap = Schema.Struct({ + plugins: Schema.Array(PluginListItem), +}); +export type PluginBootstrap = typeof PluginBootstrap.Type; + +export const PluginProcedureCallInput = Schema.Struct({ + pluginId: TrimmedNonEmptyString, + procedure: TrimmedNonEmptyString, + payload: Schema.optional(Schema.Unknown), +}); +export type PluginProcedureCallInput = typeof PluginProcedureCallInput.Type; + +export const PluginRegistryUpdatedPayload = Schema.Struct({ + ids: Schema.Array(TrimmedNonEmptyString), +}); +export type PluginRegistryUpdatedPayload = typeof PluginRegistryUpdatedPayload.Type; + +export interface PluginComposerQueryContext { + readonly triggerKind: PluginComposerTriggerKind; + readonly query: string; + readonly threadId?: string | undefined; + readonly cwd?: string | null | undefined; +} + +interface PluginComposerBaseItem { + readonly id: string; + readonly label: string; + readonly description: string; + readonly keywords?: readonly string[] | undefined; + readonly priority?: number | undefined; + readonly badge?: string | undefined; + readonly icon?: PluginComposerIcon | undefined; +} + +export type PluginComposerSelectResult = + | { + readonly type: "replace-trigger"; + readonly text: string; + } + | { + readonly type: "insert-text"; + readonly text: string; + } + | { + readonly type: "open-secondary"; + readonly title: string; + readonly items: readonly PluginComposerItem[] | Promise; + } + | { + readonly type: "none"; + }; + +export interface PluginComposerCommandItem extends PluginComposerBaseItem { + readonly type: "slash-command"; + readonly action: "insert" | "run" | "pick"; + readonly onSelect: () => PluginComposerSelectResult | Promise; +} + +export interface PluginComposerSkillItem extends PluginComposerBaseItem { + readonly type: "skill"; + readonly replacementText: string; + readonly sourceLabel: string; +} + +export interface PluginComposerPathItem extends PluginComposerBaseItem { + readonly type: "path"; + readonly path: string; + readonly pathKind: ProjectEntry["kind"]; +} + +export type PluginComposerItem = + | PluginComposerCommandItem + | PluginComposerSkillItem + | PluginComposerPathItem; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0903253301..b619318d46 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,12 +1,14 @@ import { Schema } from "effect"; -import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { PositiveInt, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + // Allow empty queries so the composer can open a second-step workspace picker + // and show top-ranked entries before the user types a filter. + query: TrimmedString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; diff --git a/packages/contracts/src/prompt.ts b/packages/contracts/src/prompt.ts new file mode 100644 index 0000000000..ecb492ffc0 --- /dev/null +++ b/packages/contracts/src/prompt.ts @@ -0,0 +1,28 @@ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const PromptSourceKind = Schema.Literals(["project", "user"]); +export type PromptSourceKind = typeof PromptSourceKind.Type; + +export const PromptSummary = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + displayName: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, + argumentHint: Schema.optional(TrimmedNonEmptyString), + sourceKind: PromptSourceKind, + sourcePath: TrimmedNonEmptyString, + defaultPrompt: TrimmedNonEmptyString, +}); +export type PromptSummary = typeof PromptSummary.Type; + +export const PromptsListInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type PromptsListInput = typeof PromptsListInput.Type; + +export const PromptsListResult = Schema.Struct({ + prompts: Schema.Array(PromptSummary), +}); +export type PromptsListResult = typeof PromptsListResult.Type; diff --git a/packages/contracts/src/skill.ts b/packages/contracts/src/skill.ts new file mode 100644 index 0000000000..e32311440c --- /dev/null +++ b/packages/contracts/src/skill.ts @@ -0,0 +1,29 @@ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const SkillSourceKind = Schema.Literals(["project", "user", "system"]); +export type SkillSourceKind = typeof SkillSourceKind.Type; + +export const SkillSummary = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + displayName: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, + sourceKind: SkillSourceKind, + sourcePath: TrimmedNonEmptyString, + allowImplicitInvocation: Schema.Boolean, + defaultPrompt: TrimmedNonEmptyString, + iconUrl: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillSummary = typeof SkillSummary.Type; + +export const SkillsListInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillsListInput = typeof SkillsListInput.Type; + +export const SkillsListResult = Schema.Struct({ + skills: Schema.Array(SkillSummary), +}); +export type SkillsListResult = typeof SkillsListResult.Type; diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index d732242ecd..e28148423b 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -73,6 +73,19 @@ it.effect("accepts git.preparePullRequestThread requests", () => }), ); +it.effect("accepts prompts.list requests", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-prompts-1", + body: { + _tag: WS_METHODS.promptsList, + cwd: "/repo", + }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.promptsList); + }), +); + it.effect("accepts typed websocket push envelopes with sequence", () => Effect.gen(function* () { const parsed = yield* decodeWsResponse({ diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b8..a70c825379 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -36,7 +36,10 @@ import { import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; +import { PluginProcedureCallInput, PluginRegistryUpdatedPayload } from "./plugin"; import { ServerConfigUpdatedPayload } from "./server"; +import { SkillsListInput } from "./skill"; +import { PromptsListInput } from "./prompt"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -47,6 +50,10 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + pluginsGetBootstrap: "plugins.getBootstrap", + pluginsCallProcedure: "plugins.callProcedure", + skillsList: "skills.list", + promptsList: "prompts.list", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -81,6 +88,7 @@ export const WS_METHODS = { export const WS_CHANNELS = { terminalEvent: "terminal.event", + pluginsRegistryUpdated: "plugins.registryUpdated", serverWelcome: "server.welcome", serverConfigUpdated: "server.configUpdated", } as const; @@ -111,6 +119,10 @@ const WebSocketRequestBody = Schema.Union([ // Project Search tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), + tagRequestBody(WS_METHODS.pluginsGetBootstrap, Schema.Struct({})), + tagRequestBody(WS_METHODS.pluginsCallProcedure, PluginProcedureCallInput), + tagRequestBody(WS_METHODS.skillsList, SkillsListInput), + tagRequestBody(WS_METHODS.promptsList, PromptsListInput), // Shell methods tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), @@ -173,6 +185,7 @@ export interface WsPushPayloadByChannel { readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; + readonly [WS_CHANNELS.pluginsRegistryUpdated]: typeof PluginRegistryUpdatedPayload.Type; readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; } @@ -196,6 +209,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema( ServerConfigUpdatedPayload, ); export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent); +export const WsPushPluginsRegistryUpdated = makeWsPushSchema( + WS_CHANNELS.pluginsRegistryUpdated, + PluginRegistryUpdatedPayload, +); export const WsPushOrchestrationDomainEvent = makeWsPushSchema( ORCHESTRATION_WS_CHANNELS.domainEvent, OrchestrationEvent, @@ -205,6 +222,7 @@ export const WsPushChannelSchema = Schema.Literals([ WS_CHANNELS.serverWelcome, WS_CHANNELS.serverConfigUpdated, WS_CHANNELS.terminalEvent, + WS_CHANNELS.pluginsRegistryUpdated, ORCHESTRATION_WS_CHANNELS.domainEvent, ]); export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; @@ -213,6 +231,7 @@ export const WsPush = Schema.Union([ WsPushServerWelcome, WsPushServerConfigUpdated, WsPushTerminalEvent, + WsPushPluginsRegistryUpdated, WsPushOrchestrationDomainEvent, ]); export type WsPush = typeof WsPush.Type; diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json new file mode 100644 index 0000000000..5f858e8208 --- /dev/null +++ b/packages/plugin-sdk/package.json @@ -0,0 +1,24 @@ +{ + "name": "@t3tools/plugin-sdk", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "prepare": "effect-language-service patch", + "typecheck": "bun x tsc --noEmit" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts new file mode 100644 index 0000000000..34daf9ed88 --- /dev/null +++ b/packages/plugin-sdk/src/index.ts @@ -0,0 +1,103 @@ +import type { + PluginComposerItem, + PluginComposerQueryContext, + PluginManifest, + PluginSlotId, +} from "@t3tools/contracts"; +import { Schema } from "effect"; + +export { Schema }; +export type { + PluginComposerIcon, + PluginComposerItem, + PluginComposerPathItem, + PluginComposerQueryContext, + PluginComposerSelectResult, + PluginComposerSkillItem, + PluginComposerTriggerKind, + PluginListItem, + PluginManifest, + PluginSlotId, +} from "@t3tools/contracts"; + +export interface ServerPluginProcedure< + TInputSchema extends Schema.Schema = Schema.Schema, + TOutputSchema extends Schema.Schema = Schema.Schema, +> { + readonly name: string; + readonly input: TInputSchema; + readonly output: TOutputSchema; + readonly handler: ( + input: Schema.Schema.Type, + ) => Schema.Schema.Type | Promise>; +} + +export interface ServerPluginContext { + readonly pluginId: string; + readonly registerProcedure: < + TInputSchema extends Schema.Schema, + TOutputSchema extends Schema.Schema, + >( + procedure: ServerPluginProcedure, + ) => () => void; + readonly onDispose: (cleanup: () => void | Promise) => void; + readonly host: { + readonly log: { + readonly info: (...args: unknown[]) => void; + readonly warn: (...args: unknown[]) => void; + readonly error: (...args: unknown[]) => void; + }; + readonly pluginStorageDir: string; + readonly skills: { + readonly list: (input: { cwd?: string }) => Promise; + }; + readonly projects: { + readonly searchEntries: (input: { + cwd: string; + query?: string; + limit?: number; + }) => Promise; + }; + }; +} + +export interface WebPluginComposerProvider { + readonly id: string; + readonly triggers: readonly PluginComposerQueryContext["triggerKind"][]; + readonly getItems: ( + input: PluginComposerQueryContext, + ) => readonly PluginComposerItem[] | Promise; +} + +export interface WebPluginContext { + readonly pluginId: string; + readonly callProcedure: (input: { + pluginId?: string; + procedure: string; + payload?: unknown; + }) => Promise; + readonly registerComposerProvider: (provider: WebPluginComposerProvider) => () => void; + readonly registerSlot: ( + slotId: PluginSlotId, + renderer: (props: Record) => unknown, + ) => () => void; + readonly onDispose: (cleanup: () => void | Promise) => void; +} + +export type ServerPluginFactory = ( + ctx: ServerPluginContext, +) => void | (() => void | Promise) | Promise void | Promise)>; + +export type WebPluginFactory = ( + ctx: WebPluginContext, +) => void | (() => void | Promise) | Promise void | Promise)>; + +export function defineServerPlugin(factory: ServerPluginFactory): ServerPluginFactory { + return factory; +} + +export function defineWebPlugin(factory: WebPluginFactory): WebPluginFactory { + return factory; +} + +export type PluginManifestShape = PluginManifest; diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json new file mode 100644 index 0000000000..92d639f99b --- /dev/null +++ b/packages/plugin-sdk/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node"], + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "warning", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] + }, + "include": ["src"] +} diff --git a/plugins/codex-composer/dist/server.js b/plugins/codex-composer/dist/server.js new file mode 100644 index 0000000000..3f11f0ebe2 --- /dev/null +++ b/plugins/codex-composer/dist/server.js @@ -0,0 +1,18 @@ +import { ProjectSearchEntriesInput, ProjectSearchEntriesResult, SkillsListInput, SkillsListResult } from "@t3tools/contracts"; +import { defineServerPlugin } from "@t3tools/plugin-sdk"; + +export default defineServerPlugin((ctx) => { + ctx.registerProcedure({ + name: "skills.list", + input: SkillsListInput, + output: SkillsListResult, + handler: (input) => ctx.host.skills.list(input), + }); + + ctx.registerProcedure({ + name: "workspace.search", + input: ProjectSearchEntriesInput, + output: ProjectSearchEntriesResult, + handler: (input) => ctx.host.projects.searchEntries(input), + }); +}); diff --git a/plugins/codex-composer/dist/web.js b/plugins/codex-composer/dist/web.js new file mode 100644 index 0000000000..928c6cc3df --- /dev/null +++ b/plugins/codex-composer/dist/web.js @@ -0,0 +1,237 @@ +function normalize(value) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function basenameOf(pathValue) { + const normalizedPath = typeof pathValue === "string" ? pathValue.replaceAll("\\", "/") : ""; + const lastSeparator = normalizedPath.lastIndexOf("/"); + return lastSeparator >= 0 ? normalizedPath.slice(lastSeparator + 1) : normalizedPath; +} + +function parentPathOf(pathValue) { + const normalizedPath = typeof pathValue === "string" ? pathValue.replaceAll("\\", "/") : ""; + const lastSeparator = normalizedPath.lastIndexOf("/"); + return lastSeparator >= 0 ? normalizedPath.slice(0, lastSeparator) : ""; +} + +function scoreSkill(skill, query) { + const normalizedQuery = normalize(query); + if (!normalizedQuery) { + return skill.sourceKind === "project" ? 0 : skill.sourceKind === "user" ? 10 : 20; + } + + const name = normalize(skill.name); + const displayName = normalize(skill.displayName); + const description = normalize(skill.description); + + if (name === normalizedQuery) return 0; + if (displayName === normalizedQuery) return 1; + if (name.startsWith(normalizedQuery)) return 2; + if (displayName.startsWith(normalizedQuery)) return 3; + if (name.includes(normalizedQuery)) return 4; + if (displayName.includes(normalizedQuery)) return 5; + if (description.includes(normalizedQuery)) return 6; + return Number.POSITIVE_INFINITY; +} + +function compareSkills(left, right, query) { + const scoreDelta = scoreSkill(left, query) - scoreSkill(right, query); + if (scoreDelta !== 0) return scoreDelta; + const sourceRank = + (left.sourceKind === "project" ? 0 : left.sourceKind === "user" ? 1 : 2) - + (right.sourceKind === "project" ? 0 : right.sourceKind === "user" ? 1 : 2); + if (sourceRank !== 0) return sourceRank; + return left.name.localeCompare(right.name); +} + +function buildSkillSourceLabel(skill) { + if (skill.sourceKind === "project") return "Project"; + if (skill.sourceKind === "user") return "User"; + return "System"; +} + +function buildSkillsSummary(skills) { + if (!Array.isArray(skills) || skills.length === 0) { + return "No skills found in this workspace or your user skill directories."; + } + const summary = skills + .slice(0, 8) + .map((skill) => `$${skill.name}`) + .join(", "); + return `Available skills: ${summary}${skills.length > 8 ? ", ..." : ""}`; +} + +function scoreSlashItem(item, query) { + const normalizedQuery = normalize(query); + if (!normalizedQuery) return 0; + + const label = normalize(item.label); + const description = normalize(item.description); + const keywords = Array.isArray(item.keywords) ? item.keywords.map(normalize) : []; + + if (label.startsWith(normalizedQuery)) return 0; + if (keywords.some((keyword) => keyword.startsWith(normalizedQuery))) return 1; + if (label.includes(normalizedQuery)) return 2; + if (keywords.some((keyword) => keyword.includes(normalizedQuery))) return 3; + if (description.includes(normalizedQuery)) return 4; + return Number.POSITIVE_INFINITY; +} + +export default function activateWeb(ctx) { + ctx.registerComposerProvider({ + id: "slash-commands", + triggers: ["slash-command"], + async getItems(input) { + const [skillsResult, workspaceResult] = await Promise.all([ + ctx + .callProcedure({ + procedure: "skills.list", + ...(input.cwd ? { payload: { cwd: input.cwd } } : {}), + }) + .catch(() => ({ skills: [] })), + input.cwd + ? ctx + .callProcedure({ + procedure: "workspace.search", + payload: { cwd: input.cwd, query: "", limit: 40 }, + }) + .catch(() => ({ entries: [] })) + : Promise.resolve({ entries: [] }), + ]); + + const secondarySkills = Array.isArray(skillsResult?.skills) + ? [...skillsResult.skills] + .sort((left, right) => compareSkills(left, right, "")) + .slice(0, 40) + .map((skill) => ({ + id: `skill:${skill.id}`, + type: "skill", + label: skill.displayName, + description: skill.description, + sourceLabel: buildSkillSourceLabel(skill), + replacementText: skill.defaultPrompt, + })) + : []; + + const secondaryWorkspaceEntries = Array.isArray(workspaceResult?.entries) + ? workspaceResult.entries.slice(0, 40).map((entry) => ({ + id: `workspace:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOf(entry.path), + description: parentPathOf(entry.path), + })) + : []; + + const items = [ + { + id: "browse-workspace-files", + type: "slash-command", + action: "pick", + label: "Browse workspace files", + description: "Open a second picker and insert an @path mention", + keywords: ["workspace", "files", "mention", "open"], + badge: "Picker", + icon: "file-search", + onSelect: () => ({ + type: "open-secondary", + title: "Workspace files", + items: secondaryWorkspaceEntries, + }), + }, + { + id: "insert-skill", + type: "slash-command", + action: "pick", + label: "Insert skill", + description: "Open the skill picker and insert a $skill mention", + keywords: ["skills", "skill", "mention"], + badge: "Picker", + icon: "sparkles", + onSelect: () => ({ + type: "open-secondary", + title: "Skills", + items: secondarySkills, + }), + }, + { + id: "list-project-skills", + type: "slash-command", + action: "run", + label: "List project skills", + description: "Insert a summary of skills available for this workspace", + keywords: ["skills", "list", "project"], + badge: "Action", + icon: "sparkles", + onSelect: async () => { + const result = await ctx.callProcedure({ + procedure: "skills.list", + ...(input.cwd ? { payload: { cwd: input.cwd } } : {}), + }); + return { + type: "replace-trigger", + text: `${buildSkillsSummary(result?.skills)} `, + }; + }, + }, + ]; + + return items + .filter((item) => scoreSlashItem(item, input.query) !== Number.POSITIVE_INFINITY) + .sort((left, right) => scoreSlashItem(left, input.query) - scoreSlashItem(right, input.query)); + }, + }); + + ctx.registerComposerProvider({ + id: "skills", + triggers: ["skill-mention", "slash-skills"], + async getItems(input) { + const result = await ctx.callProcedure({ + procedure: "skills.list", + ...(input.cwd ? { payload: { cwd: input.cwd } } : {}), + }); + const skills = Array.isArray(result?.skills) ? [...result.skills] : []; + + return skills + .filter((skill) => scoreSkill(skill, input.query) !== Number.POSITIVE_INFINITY) + .sort((left, right) => compareSkills(left, right, input.query)) + .map((skill) => ({ + id: `skill:${skill.id}`, + type: "skill", + label: skill.displayName, + description: skill.description, + sourceLabel: buildSkillSourceLabel(skill), + replacementText: skill.defaultPrompt, + })); + }, + }); + + ctx.registerComposerProvider({ + id: "workspace", + triggers: ["slash-workspace"], + async getItems(input) { + if (!input.cwd) { + return []; + } + + const result = await ctx.callProcedure({ + procedure: "workspace.search", + payload: { + cwd: input.cwd, + query: input.query, + limit: 120, + }, + }); + + return (Array.isArray(result?.entries) ? result.entries : []).map((entry) => ({ + id: `workspace:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOf(entry.path), + description: parentPathOf(entry.path), + })); + }, + }); +} diff --git a/plugins/codex-composer/package.json b/plugins/codex-composer/package.json new file mode 100644 index 0000000000..55411a73dc --- /dev/null +++ b/plugins/codex-composer/package.json @@ -0,0 +1,13 @@ +{ + "name": "@t3tools/plugin-codex-composer", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "echo 'build wiring not added yet'" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*" + } +} diff --git a/plugins/codex-composer/src/server.ts b/plugins/codex-composer/src/server.ts new file mode 100644 index 0000000000..7c0abdcec1 --- /dev/null +++ b/plugins/codex-composer/src/server.ts @@ -0,0 +1,24 @@ +import { + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, + SkillsListInput, + SkillsListResult, +} from "@t3tools/contracts"; +import { defineServerPlugin } from "@t3tools/plugin-sdk"; + +export default defineServerPlugin((ctx) => { + ctx.registerProcedure({ + name: "skills.list", + input: SkillsListInput, + output: SkillsListResult, + handler: (input) => ctx.host.skills.list(input) as Promise, + }); + + ctx.registerProcedure({ + name: "workspace.search", + input: ProjectSearchEntriesInput, + output: ProjectSearchEntriesResult, + handler: (input) => + ctx.host.projects.searchEntries(input) as Promise, + }); +}); diff --git a/plugins/codex-composer/src/web.ts b/plugins/codex-composer/src/web.ts new file mode 100644 index 0000000000..a8bf9a0be7 --- /dev/null +++ b/plugins/codex-composer/src/web.ts @@ -0,0 +1,269 @@ +import type { PluginComposerItem } from "@t3tools/contracts"; +import { defineWebPlugin } from "@t3tools/plugin-sdk"; + +function normalize(value: string | undefined): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function basenameOf(pathValue: string): string { + const normalizedPath = pathValue.replaceAll("\\", "/"); + const lastSeparator = normalizedPath.lastIndexOf("/"); + return lastSeparator >= 0 ? normalizedPath.slice(lastSeparator + 1) : normalizedPath; +} + +function parentPathOf(pathValue: string): string { + const normalizedPath = pathValue.replaceAll("\\", "/"); + const lastSeparator = normalizedPath.lastIndexOf("/"); + return lastSeparator >= 0 ? normalizedPath.slice(0, lastSeparator) : ""; +} + +function scoreSkill( + skill: { + name: string; + displayName: string; + description: string; + sourceKind: "project" | "user" | "system"; + }, + query: string, +): number { + const normalizedQuery = normalize(query); + if (!normalizedQuery) { + return skill.sourceKind === "project" ? 0 : skill.sourceKind === "user" ? 10 : 20; + } + + const name = normalize(skill.name); + const displayName = normalize(skill.displayName); + const description = normalize(skill.description); + + if (name === normalizedQuery) return 0; + if (displayName === normalizedQuery) return 1; + if (name.startsWith(normalizedQuery)) return 2; + if (displayName.startsWith(normalizedQuery)) return 3; + if (name.includes(normalizedQuery)) return 4; + if (displayName.includes(normalizedQuery)) return 5; + if (description.includes(normalizedQuery)) return 6; + return Number.POSITIVE_INFINITY; +} + +function compareSkills( + left: { + name: string; + sourceKind: "project" | "user" | "system"; + }, + right: { + name: string; + sourceKind: "project" | "user" | "system"; + }, + query: string, +): number { + const scoreDelta = + scoreSkill(left as Parameters[0], query) - + scoreSkill(right as Parameters[0], query); + if (scoreDelta !== 0) return scoreDelta; + const sourceRank = + (left.sourceKind === "project" ? 0 : left.sourceKind === "user" ? 1 : 2) - + (right.sourceKind === "project" ? 0 : right.sourceKind === "user" ? 1 : 2); + if (sourceRank !== 0) return sourceRank; + return left.name.localeCompare(right.name); +} + +function buildSkillSourceLabel(skill: { sourceKind: "project" | "user" | "system" }): string { + if (skill.sourceKind === "project") return "Project"; + if (skill.sourceKind === "user") return "User"; + return "System"; +} + +function buildSkillsSummary( + skills: ReadonlyArray<{ + name: string; + }>, +): string { + if (skills.length === 0) { + return "No skills found in this workspace or your user skill directories."; + } + const summary = skills + .slice(0, 8) + .map((skill) => `$${skill.name}`) + .join(", "); + return `Available skills: ${summary}${skills.length > 8 ? ", ..." : ""}`; +} + +function scoreSlashItem( + item: { + label: string; + description: string; + keywords?: readonly string[]; + }, + query: string, +): number { + const normalizedQuery = normalize(query); + if (!normalizedQuery) return 0; + + const label = normalize(item.label); + const description = normalize(item.description); + const keywords = item.keywords?.map(normalize) ?? []; + + if (label.startsWith(normalizedQuery)) return 0; + if (keywords.some((keyword) => keyword.startsWith(normalizedQuery))) return 1; + if (label.includes(normalizedQuery)) return 2; + if (keywords.some((keyword) => keyword.includes(normalizedQuery))) return 3; + if (description.includes(normalizedQuery)) return 4; + return Number.POSITIVE_INFINITY; +} + +export default defineWebPlugin((ctx) => { + ctx.registerComposerProvider({ + id: "slash-commands", + triggers: ["slash-command"], + async getItems(input) { + const [skillsResult, workspaceResult] = await Promise.all([ + ctx.callProcedure({ + procedure: "skills.list", + ...(input.cwd ? { payload: { cwd: input.cwd } } : {}), + }) as Promise<{ skills: Array }>, + input.cwd + ? (ctx.callProcedure({ + procedure: "workspace.search", + payload: { + cwd: input.cwd, + query: "", + limit: 40, + }, + }) as Promise<{ entries: Array }>) + : Promise.resolve({ entries: [] }), + ]); + + const secondarySkills: PluginComposerItem[] = [...(skillsResult.skills ?? [])] + .toSorted((left, right) => compareSkills(left, right, "")) + .slice(0, 40) + .map((skill) => ({ + id: `skill:${skill.id}`, + type: "skill", + label: skill.displayName, + description: skill.description, + sourceLabel: buildSkillSourceLabel(skill), + replacementText: skill.defaultPrompt, + })); + + const secondaryWorkspaceEntries: PluginComposerItem[] = [...(workspaceResult.entries ?? [])] + .slice(0, 40) + .map((entry) => ({ + id: `workspace:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOf(entry.path), + description: parentPathOf(entry.path), + })); + + const items: PluginComposerItem[] = [ + { + id: "browse-workspace-files", + type: "slash-command", + action: "pick", + label: "Browse workspace files", + description: "Open a second picker and insert an @path mention", + keywords: ["workspace", "files", "mention", "open"], + badge: "Picker", + icon: "file-search", + onSelect: () => ({ + type: "open-secondary", + title: "Workspace files", + items: secondaryWorkspaceEntries, + }), + }, + { + id: "insert-skill", + type: "slash-command", + action: "pick", + label: "Insert skill", + description: "Open the skill picker and insert a $skill mention", + keywords: ["skills", "skill", "mention"], + badge: "Picker", + icon: "sparkles", + onSelect: () => ({ + type: "open-secondary", + title: "Skills", + items: secondarySkills, + }), + }, + { + id: "list-project-skills", + type: "slash-command", + action: "run", + label: "List project skills", + description: "Insert a summary of skills available for this workspace", + keywords: ["skills", "list", "project"], + badge: "Action", + icon: "sparkles", + onSelect: async () => { + const result = (await ctx.callProcedure({ + procedure: "skills.list", + ...(input.cwd ? { payload: { cwd: input.cwd } } : {}), + })) as { skills: Array<{ name: string }> }; + return { + type: "replace-trigger" as const, + text: `${buildSkillsSummary(result.skills ?? [])} `, + }; + }, + }, + ]; + + return items + .filter((item) => scoreSlashItem(item, input.query) !== Number.POSITIVE_INFINITY) + .toSorted( + (left, right) => scoreSlashItem(left, input.query) - scoreSlashItem(right, input.query), + ); + }, + }); + + ctx.registerComposerProvider({ + id: "skills", + triggers: ["skill-mention", "slash-skills"], + async getItems(input) { + const result = (await ctx.callProcedure({ + procedure: "skills.list", + ...(input.cwd ? { payload: { cwd: input.cwd } } : {}), + })) as { skills: Array }; + + return [...(result.skills ?? [])] + .filter((skill) => scoreSkill(skill, input.query) !== Number.POSITIVE_INFINITY) + .toSorted((left, right) => compareSkills(left, right, input.query)) + .map((skill) => ({ + id: `skill:${skill.id}`, + type: "skill", + label: skill.displayName, + description: skill.description, + sourceLabel: buildSkillSourceLabel(skill), + replacementText: skill.defaultPrompt, + })); + }, + }); + + ctx.registerComposerProvider({ + id: "workspace", + triggers: ["slash-workspace"], + async getItems(input) { + if (!input.cwd) { + return []; + } + const result = (await ctx.callProcedure({ + procedure: "workspace.search", + payload: { + cwd: input.cwd, + query: input.query, + limit: 120, + }, + })) as { entries: Array }; + + return [...(result.entries ?? [])].map((entry) => ({ + id: `workspace:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOf(entry.path), + description: parentPathOf(entry.path), + })); + }, + }); +}); diff --git a/plugins/codex-composer/t3-plugin.json b/plugins/codex-composer/t3-plugin.json new file mode 100644 index 0000000000..455d192657 --- /dev/null +++ b/plugins/codex-composer/t3-plugin.json @@ -0,0 +1,9 @@ +{ + "id": "codex-composer", + "name": "Codex Composer", + "version": "0.0.1", + "hostApiVersion": "1", + "enabled": true, + "serverEntry": "dist/server.js", + "webEntry": "dist/web.js" +}