diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..2289086d7af 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -36,11 +36,15 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexProviderStatus, + makePendingCodexProvider, + probeCodexAppServerProvider, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { AUTH_PROBE_TIMEOUT_MS, type ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -161,6 +165,42 @@ export const CodexDriver: ProviderDriver = { ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); + const listSkills = Effect.fn("CodexDriver.listSkills")(function* (cwd: string) { + if (!effectiveConfig.enabled) { + return []; + } + + return yield* probeCodexAppServerProvider({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + cwd, + customModels: [], + environment: processEnv, + includeModels: false, + }).pipe( + Effect.scoped, + Effect.map((result) => result.skills), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to list Codex skills: ${cause.message}`, + cause, + }), + ), + Effect.timeoutOrElse({ + duration: Duration.millis(AUTH_PROBE_TIMEOUT_MS), + orElse: () => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Timed out listing Codex skills after ${AUTH_PROBE_TIMEOUT_MS}ms`, + }), + }), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + }); // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place @@ -209,6 +249,7 @@ export const CodexDriver: ProviderDriver = { snapshot, adapter, textGeneration, + listSkills, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a2182cfb73c..1d84968b5dc 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -286,94 +286,99 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly cwd: string; - readonly customModels?: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; -}) { - // `~` is not shell-expanded when env vars are set via `child_process.spawn`, - // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip - // "CODEX_HOME points to '~/.codex_work', but that path does not exist". - // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. - const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = { - ...input.environment, - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }; - const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { - env: environment, - extendEnv: true, - }); - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.cwd, - env: environment, - extendEnv: true, - forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, - shell: spawnCommand.shell, - }), - ) - .pipe( - Effect.mapError( - (cause) => - new CodexErrors.CodexAppServerSpawnError({ - command: `${input.binaryPath} app-server`, - cause, - }), - ), +export const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")( + function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly customModels?: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + readonly includeModels?: boolean; + }) { + // `~` is not shell-expanded when env vars are set via `child_process.spawn`, + // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip + // "CODEX_HOME points to '~/.codex_work', but that path does not exist". + // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { + env: environment, + extendEnv: true, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), ); - const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); - const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.provide(clientContext), - ); - const initialize = yield* client.request("initialize", { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); - yield* client.notify("initialized", undefined); + const initialize = yield* client.request("initialize", { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + }); + yield* client.notify("initialized", undefined); + + // Extract the version string after the first '/' in userAgent, up to the next space or the end + const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); + const version = versionMatch ? versionMatch[1] : undefined; + + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return { + account: accountResponse, + version, + models: appendCustomCodexModels([], input.customModels ?? []), + skills: [], + } satisfies CodexAppServerProviderSnapshot; + } - // Extract the version string after the first '/' in userAgent, up to the next space or the end - const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); - const version = versionMatch ? versionMatch[1] : undefined; + let skillsResponse: CodexSchema.V2SkillsListResponse; + let models: ReadonlyArray; + if (input.includeModels === false) { + skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + models = []; + } else { + [skillsResponse, models] = yield* Effect.all( + [client.request("skills/list", { cwds: [input.cwd] }), requestAllCodexModels(client)], + { concurrency: "unbounded" }, + ); + } - const accountResponse = yield* client.request("account/read", {}); - if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { account: accountResponse, version, - models: appendCustomCodexModels([], input.customModels ?? []), - skills: [], + models: appendCustomCodexModels(models, input.customModels ?? []), + skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; - } - - const [skillsResponse, models] = yield* Effect.all( - [ - client.request("skills/list", { - cwds: [input.cwd], - }), - requestAllCodexModels(client), - ], - { concurrency: "unbounded" }, - ); - - return { - account: accountResponse, - version, - models: appendCustomCodexModels(models, input.customModels ?? []), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), - } satisfies CodexAppServerProviderSnapshot; -}); + }, +); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab1145495..784b82da283 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -613,6 +613,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te skills: [], } as const satisfies ServerProvider; const refreshCalls = yield* Ref.make(0); + const listedSkillCwds = yield* Ref.make>([]); + const projectSkill = { + name: "project-review", + path: "/tmp/project/.codex/skills/project-review/SKILL.md", + enabled: true, + scope: "repo", + } as const; const instance = { instanceId: codexInstanceId, driverKind: codexDriver, @@ -635,6 +642,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], + listSkills: (cwd: string) => + Ref.update(listedSkillCwds, (cwds) => [...cwds, cwd]).pipe(Effect.as([projectSkill])), } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -664,6 +673,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); assert.strictEqual(yield* Ref.get(refreshCalls), 0); + assert.deepStrictEqual( + yield* registry.listSkills({ instanceId: codexInstanceId, cwd: "/tmp/project" }), + [projectSkill], + ); + assert.deepStrictEqual(yield* Ref.get(listedSkillCwds), ["/tmp/project"]); }).pipe(Effect.provide(runtimeServices)); }), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..f2f4f9cf376 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -486,6 +486,19 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const listSkills = Effect.fn("ProviderRegistry.listSkills")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!instance?.listSkills) { + return undefined; + } + return yield* instance.listSkills(input.cwd); + }); + /** * Diff the aggregator's live-source set against the current * `ProviderInstanceRegistry` and: @@ -688,6 +701,7 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + listSkills, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a..0adf91785f7 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProviderSkill, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -71,6 +72,9 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + readonly listSkills?: ( + cwd: string, + ) => Effect.Effect, ProviderDriverError>; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b30338..54ce1530563 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -10,16 +10,23 @@ import type { ProviderInstanceId, ProviderDriverKind, ServerProvider, + ServerProviderSkill, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import type { ProviderDriverError } from "../Errors.ts"; export type ProviderMaintenanceActionKind = "update"; export interface ProviderRegistryShape { + readonly listSkills: (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) => Effect.Effect | undefined, ProviderDriverError>; + /** * Read the latest provider snapshots for every configured instance. * Multiple snapshots may share the same `provider` kind (multiple diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce42..03265410633 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -189,6 +189,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + listSkills: () => Effect.sync(() => undefined), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b05900..3c15444f6aa 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ export const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + listSkills: () => Effect.sync(() => undefined), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..d1b005038ed 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -43,6 +43,8 @@ import { ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, + ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, @@ -286,6 +288,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], + [WS_METHODS.serverListProviderSkills, AuthOrchestrationReadScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], @@ -1251,6 +1254,31 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListProviderSkills]: (input) => + observeRpcEffect( + WS_METHODS.serverListProviderSkills, + Effect.gen(function* () { + const skills = yield* providerRegistry.listSkills(input); + if (!skills) { + return yield* new ServerProviderSkillsUnsupportedError({ + instanceId: input.instanceId, + cwd: input.cwd, + }); + } + return { skills }; + }).pipe( + Effect.mapError((cause) => + cause._tag === "ServerProviderSkillsUnsupportedError" + ? cause + : new ServerProviderSkillsError({ + instanceId: input.instanceId, + cwd: input.cwd, + cause, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 18579cda6d3..36af0efac59 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -344,6 +344,12 @@ class ComposerSkillNode extends DecoratorNode { /> ); } + + setSkillPresentation(skillLabel: string, skillDescription: string | null): void { + const writable = this.getWritable(); + writable.__skillLabel = skillLabel; + writable.__skillDescription = skillDescription; + } } function $createComposerSkillNode( @@ -354,6 +360,32 @@ function $createComposerSkillNode( return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); } +function $updateComposerSkillMetadata( + skillMetadata: ReadonlyMap, +): void { + const visit = (node: LexicalNode): void => { + if (node instanceof ComposerSkillNode) { + const metadata = skillMetadata.get(node.__skillName); + if (!metadata) { + return; + } + const { label, description } = metadata; + if (node.__skillLabel === label && node.__skillDescription === description) { + return; + } + node.setSkillPresentation(label, description); + return; + } + if ($isElementNode(node)) { + for (const child of node.getChildren()) { + visit(child); + } + } + }; + + visit($getRoot()); +} + function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } @@ -1460,12 +1492,21 @@ function ComposerPromptEditorInner({ isApplyingControlledUpdateRef.current = true; editor.update(() => { - const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || skillsChanged; + const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; if (shouldRewriteEditorState) { $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + } else if (skillsChanged) { + // Refresh chip presentation without clearing Lexical state so an + // in-flight `$` skill probe cannot reset the focused composer. + $updateComposerSkillMetadata(skillMetadataRef.current); } - if (shouldRewriteEditorState || isFocused) { + const shouldSyncSelection = + shouldRewriteEditorState || + (isFocused && + (previousSnapshot.value !== value || + previousSnapshot.cursor !== normalizedCursor || + contextsChanged)); + if (shouldSyncSelection) { $setSelectionAtComposerOffset(normalizedCursor); } }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..a9684ff98d3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -9,6 +9,7 @@ import type { RuntimeMode, ScopedThreadRef, ServerProvider, + ServerProviderSkill, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -58,6 +59,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { useProviderSkills } from "../../state/queries"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; @@ -127,6 +129,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +const EMPTY_PROVIDER_SKILLS: ReadonlyArray = []; const runtimeModeConfig: Record< RuntimeMode, @@ -935,6 +938,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const providerSkills = useProviderSkills({ + environmentId, + instanceId: selectedProviderStatus?.instanceId ?? null, + cwd: gitCwd, + enabled: composerTriggerKind === "skill", + }); + const providerSkillsTargetKey = `${environmentId ?? ""}\0${selectedProviderStatus?.instanceId ?? ""}\0${gitCwd ?? ""}`; + const [cachedProviderSkills, setCachedProviderSkills] = useState<{ + readonly targetKey: string; + readonly skills: ReadonlyArray; + } | null>(null); + useEffect(() => { + if (providerSkills.data) { + setCachedProviderSkills({ + targetKey: providerSkillsTargetKey, + skills: providerSkills.data.skills, + }); + } + }, [providerSkills.data, providerSkillsTargetKey]); + const composerProviderSkills = + providerSkills.data?.skills ?? + (cachedProviderSkills?.targetKey === providerSkillsTargetKey + ? cachedProviderSkills.skills + : selectedProviderStatus?.skills) ?? + EMPTY_PROVIDER_SKILLS; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -990,22 +1018,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + return searchProviderSkills(composerProviderSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + composerProviderSkills, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1070,7 +1102,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "skill" && providerSkills.isPending && composerMenuItems.length === 0); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -2396,7 +2429,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={composerProviderSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 79737e6109e..31e6d52a854 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -7,6 +7,7 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProviderInstanceId, ThreadId, VcsListRefsResult, VcsRef, @@ -214,6 +215,25 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { }; } +export function useProviderSkills(target: { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly cwd: string | null; + readonly enabled: boolean; +}) { + return useEnvironmentQuery( + target.enabled && + target.environmentId !== null && + target.instanceId !== null && + target.cwd !== null + ? projectEnvironment.listProviderSkills({ + environmentId: target.environmentId, + input: { instanceId: target.instanceId, cwd: target.cwd }, + }) + : null, + ); +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc32154..b4cf2b6827b 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -55,6 +55,12 @@ export function createProjectEnvironmentAtoms( JSON.stringify([environmentId, input.projectId]), }; return { + listProviderSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:providers:list-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:search-entries", tag: WS_METHODS.projectsSearchEntries, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..0dd3df5556e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -122,6 +122,10 @@ import { ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, + ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, + ServerProviderSkillsInput, + ServerProviderSkillsResult, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, @@ -154,6 +158,9 @@ export const WS_METHODS = { projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + // Provider metadata methods + serverListProviderSkills: "server.listProviderSkills", + // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -266,6 +273,16 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { + payload: ServerProviderSkillsInput, + success: ServerProviderSkillsResult, + error: Schema.Union([ + ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, + EnvironmentAuthorizationError, + ]), +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -683,6 +700,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, + WsServerListProviderSkillsRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpsertKeybindingRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..add23a2293d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -91,6 +91,42 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderSkillsInput = Schema.Struct({ + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, +}); +export type ServerProviderSkillsInput = typeof ServerProviderSkillsInput.Type; + +export const ServerProviderSkillsResult = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderSkillsResult = typeof ServerProviderSkillsResult.Type; + +export class ServerProviderSkillsUnsupportedError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsUnsupportedError", + { + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Provider instance '${this.instanceId}' does not support project skill discovery`; + } +} + +export class ServerProviderSkillsError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsError", + { + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to list provider skills for instance '${this.instanceId}'`; + } +} + /** * Availability of a configured provider instance from the runtime's POV. *