From ebbf325b9304552f77242a34d425ccce94f817f7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 15:33:50 -0700 Subject: [PATCH 1/9] Add provider skill and slash-command discovery - Probe Codex skills and Claude slash commands during provider checks - Surface discovered items in server snapshots and composer UI --- .../src/provider/Layers/ClaudeProvider.ts | 70 +++++- .../src/provider/Layers/CodexProvider.ts | 43 +++- .../provider/Layers/ProviderRegistry.test.ts | 83 +++++++ apps/server/src/provider/codexAppServer.ts | 121 ++++++++++- apps/server/src/provider/providerSnapshot.ts | 6 + apps/web/src/components/ChatView.browser.tsx | 2 + .../src/components/ComposerPromptEditor.tsx | 203 ++++++++++++++++-- .../components/KeybindingsToast.browser.tsx | 2 + apps/web/src/components/chat/ChatComposer.tsx | 123 ++++++++++- .../components/chat/ComposerCommandMenu.tsx | 183 +++++++++++++--- .../chat/ProviderModelPicker.browser.tsx | 6 + .../components/chat/TraitsPicker.browser.tsx | 4 + apps/web/src/components/composerInlineChip.ts | 3 + apps/web/src/composer-editor-mentions.test.ts | 29 +++ apps/web/src/composer-editor-mentions.ts | 73 +++++-- apps/web/src/composer-logic.test.ts | 53 +++++ apps/web/src/composer-logic.ts | 64 ++++-- apps/web/src/localApi.test.ts | 2 + .../web/src/providerSkillPresentation.test.ts | 57 +++++ apps/web/src/providerSkillPresentation.ts | 52 +++++ apps/web/src/rpc/serverState.test.ts | 2 + packages/contracts/src/server.ts | 20 ++ 22 files changed, 1105 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/providerSkillPresentation.test.ts create mode 100644 apps/web/src/providerSkillPresentation.ts diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9feec28637..c918d0cf3a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,6 +4,7 @@ import type { ServerProvider, ServerProviderModel, ServerProviderAuth, + ServerProviderSlashCommand, ServerProviderState, } from "@t3tools/contracts"; import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; @@ -387,6 +388,43 @@ export function adjustModelsForSubscription( const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +function readProbeString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nonEmptyProbeString(value: unknown): string | undefined { + const candidate = readProbeString(value)?.trim(); + return candidate ? candidate : undefined; +} + +function parseClaudeInitializationCommands( + commands: unknown, +): ReadonlyArray { + return (Array.isArray(commands) ? commands : []).flatMap((value) => { + if (!value || typeof value !== "object") { + return []; + } + + const record = value as Record; + const name = nonEmptyProbeString(record.name); + if (!name) { + return []; + } + + return [ + { + name, + ...(nonEmptyProbeString(record.description) + ? { description: nonEmptyProbeString(record.description) } + : {}), + ...(nonEmptyProbeString(record.argumentHint) + ? { argumentHint: nonEmptyProbeString(record.argumentHint) } + : {}), + } satisfies ServerProviderSlashCommand, + ]; + }); +} + /** * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. @@ -414,7 +452,10 @@ const probeClaudeCapabilities = (binaryPath: string) => { }, }); const init = await q.initializationResult(); - return { subscriptionType: init.account?.subscriptionType }; + return { + subscriptionType: init.account?.subscriptionType, + slashCommands: parseClaudeInitializationCommands(init.commands), + }; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -443,6 +484,9 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: Readonly export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( resolveSubscriptionType?: (binaryPath: string) => Effect.Effect, + resolveSlashCommands?: ( + binaryPath: string, + ) => Effect.Effect | undefined>, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -538,6 +582,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const slashCommands = + (resolveSlashCommands + ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( + Effect.orElseSucceed(() => undefined), + ) + : undefined) ?? []; + // ── Auth check + subscription detection ──────────────────────────── const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( @@ -574,6 +625,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: resolvedModels, + slashCommands, probe: { installed: true, version: parsedVersion, @@ -593,6 +645,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: resolvedModels, + slashCommands, probe: { installed: true, version: parsedVersion, @@ -610,6 +663,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: resolvedModels, + slashCommands, probe: { installed: true, version: parsedVersion, @@ -632,12 +686,18 @@ export const ClaudeProviderLive = Layer.effect( const subscriptionProbeCache = yield* Cache.make({ capacity: 1, timeToLive: Duration.minutes(5), - lookup: (binaryPath: string) => - probeClaudeCapabilities(binaryPath).pipe(Effect.map((r) => r?.subscriptionType)), + lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), }); - const checkProvider = checkClaudeProviderStatus((binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath), + const checkProvider = checkClaudeProviderStatus( + (binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath).pipe( + Effect.map((probe) => probe?.subscriptionType), + ), + (binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath).pipe( + Effect.map((probe) => probe?.slashCommands), + ), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3509fa9257..4060217684 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -5,6 +5,7 @@ import type { ServerProvider, ServerProviderModel, ServerProviderAuth, + ServerProviderSkill, ServerProviderState, } from "@t3tools/contracts"; import { @@ -44,7 +45,7 @@ import { codexAuthSubType, type CodexAccountSnapshot, } from "../codexAccount"; -import { probeCodexAccount } from "../codexAppServer"; +import { probeCodexDiscovery } from "../codexAppServer"; import { CodexProvider } from "../Services/CodexProvider"; import { ServerSettingsService } from "../../serverSettings"; import { ServerSettingsError } from "@t3tools/contracts"; @@ -304,8 +305,9 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; const probeCodexCapabilities = (input: { readonly binaryPath: string; readonly homePath?: string; + readonly cwd: string; }) => - Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal })).pipe( + Effect.tryPromise((signal) => probeCodexDiscovery({ ...input, signal })).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { @@ -334,6 +336,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly binaryPath: string; readonly homePath?: string; }) => Effect.Effect, + resolveSkills?: (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + }) => Effect.Effect | undefined>, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -449,12 +456,22 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); } + const skills = + (resolveSkills + ? yield* resolveSkills({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + cwd: process.cwd(), + }).pipe(Effect.orElseSucceed(() => undefined)) + : undefined) ?? []; + if (yield* hasCustomModelProvider) { return buildServerProvider({ provider: PROVIDER, enabled: codexSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version: parsedVersion, @@ -484,6 +501,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -503,6 +521,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -521,6 +540,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -546,16 +566,29 @@ export const CodexProviderLive = Layer.effect( capacity: 4, timeToLive: Duration.minutes(5), lookup: (key: string) => { - const [binaryPath, homePath] = JSON.parse(key) as [string, string | undefined]; + const [binaryPath, homePath, cwd] = JSON.parse(key) as [string, string | undefined, string]; return probeCodexCapabilities({ binaryPath, + cwd, ...(homePath ? { homePath } : {}), }); }, }); - const checkProvider = checkCodexProviderStatus((input) => - Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])), + const getDiscovery = (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + }) => + Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath, input.cwd])); + + const checkProvider = checkCodexProviderStatus( + (input) => + getDiscovery({ + ...input, + cwd: process.cwd(), + }).pipe(Effect.map((discovery) => discovery?.account)), + (input) => getDiscovery(input).pipe(Effect.map((discovery) => discovery?.skills)), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ca27371b61..54c65a2893 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -220,6 +220,49 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes probed codex skills in the provider snapshot", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus( + () => + Effect.succeed({ + type: "chatgpt" as const, + planType: "pro" as const, + sparkEnabled: true, + }), + () => + Effect.succeed([ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]), + ); + + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides spark from codex models for unsupported chatgpt plans", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -498,6 +541,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( checkedAt: "2026-03-25T00:00:00.000Z", version: "1.0.0", models: [], + slashCommands: [], + skills: [], }, { provider: "claudeAgent", @@ -508,6 +553,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( checkedAt: "2026-03-25T00:00:00.000Z", version: "1.0.0", models: [], + slashCommands: [], + skills: [], }, ] as const satisfies ReadonlyArray; @@ -886,6 +933,42 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes probed claude slash commands in the provider snapshot", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + () => + Effect.succeed([ + { + name: "review", + description: "Review a pull request", + }, + ]), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "review", + description: "Review a pull request", + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index d25fc3533e..de028a9213 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; +import type { ServerProviderSkill } from "@t3tools/contracts"; import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; interface JsonRpcProbeResponse { @@ -10,10 +11,74 @@ interface JsonRpcProbeResponse { }; } +export interface CodexDiscoverySnapshot { + readonly account: CodexAccountSnapshot; + readonly skills: ReadonlyArray; +} + function readErrorMessage(response: JsonRpcProbeResponse): string | undefined { return typeof response.error?.message === "string" ? response.error.message : undefined; } +function readObject(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function readArray(value: unknown): ReadonlyArray | undefined { + return Array.isArray(value) ? value : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nonEmptyTrimmed(value: unknown): string | undefined { + const candidate = readString(value)?.trim(); + return candidate ? candidate : undefined; +} + +function parseCodexSkillsResult(result: unknown, cwd: string): ReadonlyArray { + const resultRecord = readObject(result); + const dataBuckets = readArray(resultRecord?.data) ?? []; + const matchingBucket = dataBuckets.find( + (value) => nonEmptyTrimmed(readObject(value)?.cwd) === cwd, + ); + const rawSkills = + readArray(readObject(matchingBucket)?.skills) ?? readArray(resultRecord?.skills) ?? []; + + return rawSkills.flatMap((value) => { + const skill = readObject(value); + const display = readObject(skill?.interface); + const name = nonEmptyTrimmed(skill?.name); + const path = nonEmptyTrimmed(skill?.path); + if (!name || !path) { + return []; + } + + return [ + { + name, + path, + enabled: skill?.enabled !== false, + ...(nonEmptyTrimmed(skill?.description) + ? { description: nonEmptyTrimmed(skill?.description) } + : {}), + ...(nonEmptyTrimmed(skill?.scope) ? { scope: nonEmptyTrimmed(skill?.scope) } : {}), + ...(nonEmptyTrimmed(display?.displayName) + ? { displayName: nonEmptyTrimmed(display?.displayName) } + : {}), + ...(nonEmptyTrimmed(skill?.shortDescription) || nonEmptyTrimmed(display?.shortDescription) + ? { + shortDescription: + nonEmptyTrimmed(skill?.shortDescription) ?? + nonEmptyTrimmed(display?.shortDescription), + } + : {}), + } satisfies ServerProviderSkill, + ]; + }); +} + export function buildCodexInitializeParams() { return { clientInfo: { @@ -40,11 +105,12 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo child.kill(); } -export async function probeCodexAccount(input: { +export async function probeCodexDiscovery(input: { readonly binaryPath: string; readonly homePath?: string; + readonly cwd: string; readonly signal?: AbortSignal; -}): Promise { +}): Promise { return await new Promise((resolve, reject) => { const child = spawn(input.binaryPath, ["app-server"], { env: { @@ -57,6 +123,8 @@ export async function probeCodexAccount(input: { const output = readline.createInterface({ input: child.stdout }); let completed = false; + let account: CodexAccountSnapshot | undefined; + let skills: ReadonlyArray | undefined; const cleanup = () => { output.removeAllListeners(); @@ -79,15 +147,25 @@ export async function probeCodexAccount(input: { reject( error instanceof Error ? error - : new Error(`Codex account probe failed: ${String(error)}.`), + : new Error(`Codex discovery probe failed: ${String(error)}.`), ), ); + const maybeResolve = () => { + if (account && skills !== undefined) { + const resolvedAccount = account; + const resolvedSkills = skills; + finish(() => resolve({ account: resolvedAccount, skills: resolvedSkills })); + } + }; + if (input.signal?.aborted) { - fail(new Error("Codex account probe aborted.")); + fail(new Error("Codex discovery probe aborted.")); return; } - input.signal?.addEventListener("abort", () => fail(new Error("Codex account probe aborted."))); + input.signal?.addEventListener("abort", () => + fail(new Error("Codex discovery probe aborted.")), + ); const writeMessage = (message: unknown) => { if (!child.stdin.writable) { @@ -103,7 +181,7 @@ export async function probeCodexAccount(input: { try { parsed = JSON.parse(line); } catch { - fail(new Error("Received invalid JSON from codex app-server during account probe.")); + fail(new Error("Received invalid JSON from codex app-server during discovery probe.")); return; } @@ -120,18 +198,32 @@ export async function probeCodexAccount(input: { } writeMessage({ method: "initialized" }); - writeMessage({ id: 2, method: "account/read", params: {} }); + writeMessage({ id: 2, method: "skills/list", params: { cwds: [input.cwd] } }); + writeMessage({ id: 3, method: "account/read", params: {} }); return; } if (response.id === 2) { + const errorMessage = readErrorMessage(response); + if (errorMessage) { + fail(new Error(`skills/list failed: ${errorMessage}`)); + return; + } + + skills = parseCodexSkillsResult(response.result, input.cwd); + maybeResolve(); + return; + } + + if (response.id === 3) { const errorMessage = readErrorMessage(response); if (errorMessage) { fail(new Error(`account/read failed: ${errorMessage}`)); return; } - finish(() => resolve(readCodexAccountSnapshot(response.result))); + account = readCodexAccountSnapshot(response.result); + maybeResolve(); } }); @@ -152,3 +244,16 @@ export async function probeCodexAccount(input: { }); }); } + +export async function probeCodexAccount(input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly signal?: AbortSignal; +}): Promise { + return ( + await probeCodexDiscovery({ + ...input, + cwd: process.cwd(), + }) + ).account; +} diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 4c80d78e20..4e7c141fd6 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -2,6 +2,8 @@ import type { ModelCapabilities, ServerProvider, ServerProviderAuth, + ServerProviderSkill, + ServerProviderSlashCommand, ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; @@ -131,6 +133,8 @@ export function buildServerProvider(input: { enabled: boolean; checkedAt: string; models: ReadonlyArray; + slashCommands?: ReadonlyArray; + skills?: ReadonlyArray; probe: ProviderProbeResult; }): ServerProvider { return { @@ -143,6 +147,8 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, + slashCommands: [...(input.slashCommands ?? [])], + skills: [...(input.skills ?? [])], }; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index de51449704..4342bb8c0e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -169,6 +169,8 @@ function createBaseServerConfig(): ServerConfig { auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], + slashCommands: [], + skills: [], }, ], availableEditors: [], diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 430efffa21..8c79535990 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -5,6 +5,7 @@ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; +import { type ServerProviderSkill } from "@t3tools/contracts"; import { $applyNodeReplacement, $createRangeSelection, @@ -73,8 +74,10 @@ import { COMPOSER_INLINE_CHIP_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; +import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; const SURROUND_SYMBOLS: [string, string][] = [ @@ -102,6 +105,16 @@ type SerializedComposerMentionNode = Spread< SerializedTextNode >; +type SerializedComposerSkillNode = Spread< + { + skillName: string; + skillLabel?: string; + type: "composer-skill"; + version: 1; + }, + SerializedTextNode +>; + type SerializedComposerTerminalContextNode = Spread< { context: TerminalContextDraft; @@ -189,6 +202,108 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { return $applyNodeReplacement(new ComposerMentionNode(path)); } +const SKILL_CHIP_ICON_SVG = ``; + +function renderSkillChipDom(container: HTMLElement, skillLabel: string): void { + container.textContent = ""; + container.style.setProperty("user-select", "none"); + container.style.setProperty("-webkit-user-select", "none"); + + const icon = document.createElement("span"); + icon.ariaHidden = "true"; + icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; + icon.innerHTML = SKILL_CHIP_ICON_SVG; + + const label = document.createElement("span"); + label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; + label.textContent = skillLabel; + + container.append(icon, label); +} + +class ComposerSkillNode extends TextNode { + __skillName: string; + __skillLabel: string; + + static override getType(): string { + return "composer-skill"; + } + + static override clone(node: ComposerSkillNode): ComposerSkillNode { + return new ComposerSkillNode(node.__skillName, node.__skillLabel, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { + return $createComposerSkillNode( + serializedNode.skillName, + serializedNode.skillLabel ?? serializedNode.skillName, + ); + } + + constructor(skillName: string, skillLabel: string, key?: NodeKey) { + const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; + super(`$${normalizedSkillName}`, key); + this.__skillName = normalizedSkillName; + this.__skillLabel = skillLabel; + } + + override exportJSON(): SerializedComposerSkillNode { + return { + ...super.exportJSON(), + skillName: this.__skillName, + skillLabel: this.__skillLabel, + type: "composer-skill", + version: 1, + }; + } + + override createDOM(_config: EditorConfig): HTMLElement { + const dom = document.createElement("span"); + dom.className = COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + renderSkillChipDom(dom, this.__skillLabel); + return dom; + } + + override updateDOM( + prevNode: ComposerSkillNode, + dom: HTMLElement, + _config: EditorConfig, + ): boolean { + dom.className = COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME; + dom.contentEditable = "false"; + if ( + prevNode.__text !== this.__text || + prevNode.__skillName !== this.__skillName || + prevNode.__skillLabel !== this.__skillLabel + ) { + renderSkillChipDom(dom, this.__skillLabel); + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerSkillNode(skillName: string, skillLabel: string): ComposerSkillNode { + return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel)); +} + function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } @@ -253,11 +368,16 @@ function $createComposerTerminalContextNode( return $applyNodeReplacement(new ComposerTerminalContextNode(context)); } -type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; +type ComposerInlineTokenNode = + | ComposerMentionNode + | ComposerSkillNode + | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { return ( - candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode + candidate instanceof ComposerMentionNode || + candidate instanceof ComposerSkillNode || + candidate instanceof ComposerTerminalContextNode ); } @@ -302,6 +422,25 @@ function terminalContextSignature(contexts: ReadonlyArray) .join("\u001e"); } +function skillSignature(skills: ReadonlyArray): string { + return skills + .map((skill) => + [ + skill.name, + skill.displayName ?? "", + skill.shortDescription ?? "", + skill.path, + skill.scope ?? "", + skill.enabled ? "1" : "0", + ].join("\u001f"), + ) + .join("\u001e"); +} + +function skillLabelByName(skills: ReadonlyArray): ReadonlyMap { + return new Map(skills.map((skill) => [skill.name, formatProviderSkillDisplayName(skill)])); +} + function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); @@ -410,7 +549,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); @@ -457,7 +596,7 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); @@ -488,7 +627,7 @@ function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { - if (node instanceof ComposerMentionNode) { + if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { return findSelectionPointForInlineToken(node, remainingRef); } if (node instanceof ComposerTerminalContextNode) { @@ -657,6 +796,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, + skillsByName: ReadonlyMap, ): void { const root = $getRoot(); root.clear(); @@ -669,6 +809,15 @@ function $setComposerEditorPrompt( paragraph.append($createComposerMentionNode(segment.path)); continue; } + if (segment.type === "skill") { + paragraph.append( + $createComposerSkillNode( + segment.name, + skillsByName.get(segment.name) ?? formatProviderSkillDisplayName({ name: segment.name }), + ), + ); + continue; + } if (segment.type === "terminal-context") { if (segment.context) { paragraph.append($createComposerTerminalContextNode(segment.context)); @@ -705,6 +854,7 @@ interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; + skills: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; @@ -946,9 +1096,11 @@ function ComposerInlineTokenBackspacePlugin() { function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; + skills: ReadonlyArray; }) { const [editor] = useLexicalComposerContext(); const terminalContextsRef = useRef(props.terminalContexts); + const skillsByNameRef = useRef(skillLabelByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ value: string; expandedStart: number; @@ -964,6 +1116,10 @@ function ComposerSurroundSelectionPlugin(props: { terminalContextsRef.current = props.terminalContexts; }, [props.terminalContexts]); + useEffect(() => { + skillsByNameRef.current = skillLabelByName(props.skills); + }, [props.skills]); + const applySurroundInsertion = useCallback( (inputData: string): boolean => { const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); @@ -1009,7 +1165,7 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, terminalContextsRef.current); + $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillsByNameRef.current); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, @@ -1211,6 +1367,7 @@ function ComposerPromptEditorInner({ value, cursor, terminalContexts, + skills, disabled, placeholder, className, @@ -1225,6 +1382,9 @@ function ComposerPromptEditorInner({ const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); + const skillsSignature = skillSignature(skills); + const skillsSignatureRef = useRef(skillsSignature); + const skillsByNameRef = useRef(skillLabelByName(skills)); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -1241,6 +1401,10 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); + useEffect(() => { + skillsByNameRef.current = skillLabelByName(skills); + }, [skills]); + useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); @@ -1249,10 +1413,12 @@ function ComposerPromptEditorInner({ const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; + const skillsChanged = skillsSignatureRef.current !== skillsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && - !contextsChanged + !contextsChanged && + !skillsChanged ) { return; } @@ -1264,18 +1430,20 @@ function ComposerPromptEditorInner({ terminalContextIds: terminalContexts.map((context) => context.id), }; terminalContextsSignatureRef.current = terminalContextsSignature; + skillsSignatureRef.current = skillsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !contextsChanged && !isFocused) { + if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { - const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; + const shouldRewriteEditorState = + previousSnapshot.value !== value || contextsChanged || skillsChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts); + $setComposerEditorPrompt(value, terminalContexts, skillsByNameRef.current); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1284,7 +1452,7 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, terminalContexts, terminalContextsSignature, value]); + }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]); const focusAt = useCallback( (nextCursor: number) => { @@ -1442,7 +1610,7 @@ function ComposerPromptEditorInner({ /> - + @@ -1460,6 +1628,7 @@ export const ComposerPromptEditor = forwardRef< value, cursor, terminalContexts, + skills, disabled, placeholder, className, @@ -1472,13 +1641,18 @@ export const ComposerPromptEditor = forwardRef< ) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); + const initialSkillsByNameRef = useRef(skillLabelByName(skills)); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerTerminalContextNode], + nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode], editorState: () => { - $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current); + $setComposerEditorPrompt( + initialValueRef.current, + initialTerminalContextsRef.current, + initialSkillsByNameRef.current, + ); }, onError: (error) => { throw error; @@ -1493,6 +1667,7 @@ export const ComposerPromptEditor = forwardRef< value={value} cursor={cursor} terminalContexts={terminalContexts} + skills={skills} disabled={disabled} placeholder={placeholder} onRemoveTerminalContext={onRemoveTerminalContext} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 68450256fe..67186b7510 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -80,6 +80,8 @@ function createBaseServerConfig(): ServerConfig { auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], + slashCommands: [], + skills: [], }, ], availableEditors: [], diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 26413937d6..bd416b3d5a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -100,6 +100,7 @@ import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; +import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -560,6 +561,10 @@ export const ChatComposer = memo( }); const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); + const selectedProviderStatus = useMemo( + () => providerStatuses.find((provider) => provider.provider === selectedProvider), + [providerStatuses, selectedProvider], + ); const composerProviderState = useMemo( () => @@ -704,7 +709,7 @@ export const ChatComposer = memo( })); } if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ + const builtInSlashCommandItems = [ { id: "slash:model", type: "slash-command", @@ -727,14 +732,59 @@ export const ChatComposer = memo( description: "Switch this thread back to normal build mode", }, ] satisfies ReadonlyArray>; + const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( + (command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command: { + name: command.name, + ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), + }, + label: `/${command.name}`, + description: command.description ?? command.argumentHint ?? "Run provider command", + }), + ); const query = composerTrigger.query.trim().toLowerCase(); + const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; if (!query) { - return [...slashCommandItems]; + return slashCommandItems; } return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), + (item) => + item.label.slice(1).toLowerCase().includes(query) || + item.description.toLowerCase().includes(query) || + (item.type === "slash-command" + ? item.command.includes(query) + : item.command.name.toLowerCase().includes(query)), ); } + if (composerTrigger.kind === "skill") { + const query = composerTrigger.query.trim().toLowerCase(); + return (selectedProviderStatus?.skills ?? []) + .filter((skill) => skill.enabled) + .filter((skill) => { + if (!query) return true; + return ( + skill.name.toLowerCase().includes(query) || + skill.displayName?.toLowerCase().includes(query) || + skill.shortDescription?.toLowerCase().includes(query) || + skill.description?.toLowerCase().includes(query) || + skill.scope?.toLowerCase().includes(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 searchableModelOptions .filter(({ searchSlug, searchName, searchProvider }) => { const query = composerTrigger.query.trim().toLowerCase(); @@ -753,7 +803,13 @@ export const ChatComposer = memo( label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [ + composerTrigger, + searchableModelOptions, + selectedProvider, + selectedProviderStatus, + workspaceEntries, + ]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( @@ -764,9 +820,11 @@ export const ChatComposer = memo( [composerHighlightedItemId, composerMenuItems], ); - composerMenuOpenRef.current = composerMenuOpen; - composerMenuItemsRef.current = composerMenuItems; - activeComposerMenuItemRef.current = activeComposerMenuItem; + useEffect(() => { + composerMenuOpenRef.current = composerMenuOpen; + composerMenuItemsRef.current = composerMenuItems; + activeComposerMenuItemRef.current = activeComposerMenuItem; + }, [activeComposerMenuItem, composerMenuItems, composerMenuOpen]); const nonPersistedComposerImageIdSet = useMemo( () => new Set(nonPersistedComposerImageIds), @@ -810,14 +868,21 @@ export const ChatComposer = memo( ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); + const composerMenuEmptyState = useMemo(() => { + if (composerTriggerKind === "skill") { + return "No skills found. Try / to browse provider commands."; + } + return composerTriggerKind === "path" + ? "No matching files or folders." + : "No matching command."; + }, [composerTriggerKind]); // ------------------------------------------------------------------ // Provider traits UI // ------------------------------------------------------------------ const setPromptFromTraits = useCallback( (nextPrompt: string) => { - const currentPrompt = promptRef.current; - if (nextPrompt === currentPrompt) { + if (nextPrompt === prompt) { scheduleComposerFocus(); return; } @@ -828,7 +893,7 @@ export const ChatComposer = memo( setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [composerDraftTarget, prompt, promptRef, scheduleComposerFocus, setComposerDraftPrompt], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -1333,6 +1398,42 @@ export const ChatComposer = memo( } return; } + if (item.type === "provider-slash-command") { + const replacement = `/${item.command.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "skill") { + const replacement = `$${item.skill.name} `; + 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; + } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -1676,6 +1777,7 @@ export const ChatComposer = memo( resolvedTheme={resolvedTheme} isLoading={isComposerMenuLoading} triggerKind={composerTriggerKind} + emptyStateText={composerMenuEmptyState} activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlighted} onSelect={onSelectComposerItem} @@ -1765,6 +1867,7 @@ export const ChatComposer = memo( ? composerTerminalContexts : [] } + skills={selectedProviderStatus?.skills ?? []} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} onCommandKeyDown={onComposerCommandKey} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index fc7ea27c29..007fd7c2ed 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,10 +1,19 @@ -import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useRef } from "react"; -import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; +import { type ProjectEntry, type ProviderKind, type ServerProviderSkill } from "@t3tools/contracts"; import { BotIcon } from "lucide-react"; +import { memo, useLayoutEffect, useMemo, useRef } from "react"; + +import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; +import { formatProviderSkillInstallSource } from "~/providerSkillPresentation"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; -import { Command, CommandItem, CommandList } from "../ui/command"; +import { + Command, + CommandGroup, + CommandGroupLabel, + CommandItem, + CommandList, + CommandSeparator, +} from "../ui/command"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; export type ComposerCommandItem = @@ -23,6 +32,17 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "provider-slash-command"; + provider: ProviderKind; + command: { + name: string; + argumentHint?: string; + }; + label: string; + description: string; + } | { id: string; type: "model"; @@ -30,18 +50,80 @@ export type ComposerCommandItem = model: string; label: string; description: string; + } + | { + id: string; + type: "skill"; + provider: ProviderKind; + skill: ServerProviderSkill; + label: string; + description: string; }; +type ComposerCommandGroup = { + id: string; + label: string | null; + items: ComposerCommandItem[]; +}; + +function SkillGlyph(props: { className?: string }) { + return ( + + ); +} + +function groupCommandItems( + items: ComposerCommandItem[], + triggerKind: ComposerTriggerKind | null, +): ComposerCommandGroup[] { + if (triggerKind === "skill") { + return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : []; + } + if (triggerKind !== "slash-command") { + return [{ id: "default", label: null, items }]; + } + + const builtInItems = items.filter((item) => item.type === "slash-command"); + const providerItems = items.filter((item) => item.type === "provider-slash-command"); + + const groups: ComposerCommandGroup[] = []; + if (builtInItems.length > 0) { + groups.push({ id: "built-in", label: "Built-in", items: builtInItems }); + } + if (providerItems.length > 0) { + groups.push({ id: "provider", label: "Provider", items: providerItems }); + } + return groups; +} + export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; + emptyStateText?: string; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { const listRef = useRef(null); + const groups = useMemo( + () => groupCommandItems(props.items, props.triggerKind), + [props.items, props.triggerKind], + ); useLayoutEffect(() => { if (!props.activeItemId || !listRef.current) return; @@ -65,27 +147,56 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ref={listRef} className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" > - - {props.items.map((item) => ( - + + {groups.map((group, groupIndex) => ( +
+ {groupIndex > 0 ? : null} + + {group.label ? ( + + {group.label} + + ) : null} + {group.items.map((item) => ( + + ))} + +
))}
- {props.items.length === 0 && ( -

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

- )} + {props.items.length === 0 ? ( +
+ {props.triggerKind === "skill" ? ( + + + Skills + +

+ {props.isLoading + ? "Searching workspace skills..." + : (props.emptyStateText ?? + "No skills found. Try / to browse provider commands.")} +

+
+ ) : ( +

+ {props.isLoading + ? "Searching workspace files..." + : (props.emptyStateText ?? + (props.triggerKind === "path" + ? "No matching files or folders." + : "No matching command."))} +

+ )} +
+ ) : null} ); @@ -98,6 +209,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { onHighlight: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { + const skillSourceLabel = + props.item.type === "skill" ? formatProviderSkillInstallSource(props.item.skill) : null; + return ( ) : null} {props.item.type === "slash-command" ? ( - + + ) : null} + {props.item.type === "provider-slash-command" ? ( + + + + ) : null} + {props.item.type === "skill" ? ( + + + ) : null} {props.item.type === "model" ? ( model ) : null} - - {props.item.label} + + {props.item.label} + + {props.item.description} + - {props.item.description} + {skillSourceLabel ? ( + {skillSourceLabel} + ) : null} ); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 13fe6faba2..abedcd6eeb 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -24,6 +24,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), + slashCommands: [], + skills: [], models: [ { slug: "gpt-5-codex", @@ -59,6 +61,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), + slashCommands: [], + skills: [], models: [ { slug: "claude-opus-4-6", @@ -120,6 +124,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), models, + slashCommands: [], + skills: [], }; } diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 4dc7240c13..d852d0ddea 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -44,6 +44,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + slashCommands: [], + skills: [], models: [ { slug: "gpt-5.4", @@ -70,6 +72,8 @@ const TEST_PROVIDERS: ReadonlyArray = [ status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + slashCommands: [], + skills: [], models: [ { slug: "claude-opus-4-6", diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index 273f4204e6..bf869ee31d 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -5,5 +5,8 @@ export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-8 export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight"; +export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = + "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; + export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index e42dac977e..d723114b40 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -29,6 +29,20 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); + it("splits skill tokens followed by whitespace into skill segments", () => { + expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([ + { type: "text", text: "Use " }, + { type: "skill", name: "review-follow-up" }, + { type: "text", text: " please" }, + ]); + }); + + it("does not convert an incomplete trailing skill token", () => { + expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([ + { type: "text", text: "Use $review-follow-up" }, + ]); + }); + it("keeps inline terminal context placeholders at their prompt positions", () => { expect( splitPromptIntoComposerSegments( @@ -53,6 +67,21 @@ describe("splitPromptIntoComposerSegments", () => { { type: "text", text: "tail" }, ]); }); + + it("keeps skill parsing alongside mentions and terminal placeholders", () => { + expect( + splitPromptIntoComposerSegments( + `Inspect ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}$review-follow-up after @AGENTS.md `, + ), + ).toEqual([ + { type: "text", text: "Inspect " }, + { type: "terminal-context", context: null }, + { type: "skill", name: "review-follow-up" }, + { type: "text", text: " after " }, + { type: "mention", path: "AGENTS.md" }, + { type: "text", text: " " }, + ]); + }); }); describe("selectionTouchesMentionBoundary", () => { diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index ee70c6d741..9f4492cabf 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -12,12 +12,17 @@ export type ComposerPromptSegment = type: "mention"; path: string; } + | { + type: "skill"; + name: string; + } | { type: "terminal-context"; context: TerminalContextDraft | null; }; const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; function rangeIncludesIndex(start: number, end: number, index: number): boolean { return start <= index && index < end; @@ -33,6 +38,50 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void segments.push({ type: "text", text }); } +type InlineTokenMatch = + | { + type: "mention"; + value: string; + start: number; + end: number; + } + | { + type: "skill"; + value: string; + start: number; + end: number; + }; + +function collectInlineTokenMatches(text: string): InlineTokenMatch[] { + const matches: InlineTokenMatch[] = []; + + for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const path = match[2] ?? ""; + const matchIndex = match.index ?? 0; + const start = matchIndex + prefix.length; + const end = start + fullMatch.length - prefix.length; + if (path.length > 0) { + matches.push({ type: "mention", value: path, start, end }); + } + } + + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const skillName = match[2] ?? ""; + const matchIndex = match.index ?? 0; + const start = matchIndex + prefix.length; + const end = start + fullMatch.length - prefix.length; + if (skillName.length > 0) { + matches.push({ type: "skill", value: skillName, start, end }); + } + } + + return matches.toSorted((left, right) => left.start - right.start); +} + function forEachPromptSegmentSlice( prompt: string, visitor: ( @@ -117,26 +166,24 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen return segments; } + const tokenMatches = collectInlineTokenMatches(text); let cursor = 0; - for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const path = match[2] ?? ""; - const matchIndex = match.index ?? 0; - const mentionStart = matchIndex + prefix.length; - const mentionEnd = mentionStart + fullMatch.length - prefix.length; + for (const match of tokenMatches) { + if (match.start < cursor) { + continue; + } - if (mentionStart > cursor) { - pushTextSegment(segments, text.slice(cursor, mentionStart)); + if (match.start > cursor) { + pushTextSegment(segments, text.slice(cursor, match.start)); } - if (path.length > 0) { - segments.push({ type: "mention", path }); + if (match.type === "mention") { + segments.push({ type: "mention", path: match.value }); } else { - pushTextSegment(segments, text.slice(mentionStart, mentionEnd)); + segments.push({ type: "skill", name: match.value }); } - cursor = mentionEnd; + cursor = match.end; } if (cursor < text.length) { diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 44f32bef9a..1c18af54e6 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -60,6 +60,30 @@ describe("detectComposerTrigger", () => { }); }); + it("keeps slash command detection active for provider commands", () => { + const text = "/rev"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-command", + query: "rev", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("detects $skill trigger at cursor", () => { + const text = "Use $gh-fi"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "skill", + query: "gh-fi", + rangeStart: "Use ".length, + rangeEnd: text.length, + }); + }); + it("detects @path trigger in the middle of existing text", () => { // User typed @ between "inspect " and "in this sentence" const text = "Please inspect @in this sentence"; @@ -133,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => { expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); }); + + it("maps collapsed skill cursor to expanded text cursor", () => { + const text = "run $review-follow-up then"; + const collapsedCursorAfterSkill = "run ".length + 2; + const expandedCursorAfterSkill = "run $review-follow-up ".length; + + expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe( + expandedCursorAfterSkill, + ); + }); }); describe("collapseExpandedComposerCursor", () => { @@ -158,6 +192,16 @@ describe("collapseExpandedComposerCursor", () => { expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); }); + + it("maps expanded skill cursor back to collapsed cursor", () => { + const text = "run $review-follow-up then"; + const collapsedCursorAfterSkill = "run ".length + 2; + const expandedCursorAfterSkill = "run $review-follow-up ".length; + + expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe( + collapsedCursorAfterSkill, + ); + }); }); describe("clampCollapsedComposerCursor", () => { @@ -233,6 +277,15 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => { expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true); expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true); }); + + it("treats skill pills as inline tokens for adjacency checks", () => { + const text = "run $review-follow-up next"; + const tokenStart = "run ".length; + const tokenEnd = tokenStart + 1; + + expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true); + expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true); + }); }); describe("parseStandaloneComposerSlashCommand", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c8e62ebdcc..a5b26b0e2d 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,7 +1,7 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; -export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; +export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; export interface ComposerTrigger { @@ -11,9 +11,12 @@ 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" }, + segment: + | { type: "text"; text: string } + | { type: "mention" } + | { type: "skill" } + | { type: "terminal-context" }, ): boolean => segment.type !== "text"; function clampCursor(text: string, cursor: number): number { @@ -59,6 +62,15 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) expandedCursor += expandedLength; continue; } + if (segment.type === "skill") { + const expandedLength = segment.name.length + 1; + if (remaining <= 1) { + return expandedCursor + (remaining === 0 ? 0 : expandedLength); + } + remaining -= 1; + expandedCursor += expandedLength; + continue; + } if (segment.type === "terminal-context") { if (remaining <= 1) { return expandedCursor + remaining; @@ -80,7 +92,11 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) } function collapsedSegmentLength( - segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, + segment: + | { type: "text"; text: string } + | { type: "mention" } + | { type: "skill" } + | { type: "terminal-context" }, ): number { if (segment.type === "text") { return segment.text.length; @@ -90,7 +106,10 @@ function collapsedSegmentLength( function clampCollapsedComposerCursorForSegments( segments: ReadonlyArray< - { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" } + | { type: "text"; text: string } + | { type: "mention" } + | { type: "skill" } + | { type: "terminal-context" } >, cursorInput: number, ): number { @@ -134,6 +153,18 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number collapsedCursor += 1; continue; } + if (segment.type === "skill") { + const expandedLength = segment.name.length + 1; + if (remaining === 0) { + return collapsedCursor; + } + if (remaining <= expandedLength) { + return collapsedCursor + 1; + } + remaining -= expandedLength; + collapsedCursor += 1; + continue; + } if (segment.type === "terminal-context") { if (remaining <= 1) { return collapsedCursor + remaining; @@ -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", + query: token.slice(1), + rangeStart: tokenStart, + rangeEnd: cursor, + }; + } if (!token.startsWith("@")) { return null; } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index bd04cabfaa..8b84f4806a 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -210,6 +210,8 @@ const defaultProviders: ReadonlyArray = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], + slashCommands: [], + skills: [], }, ]; diff --git a/apps/web/src/providerSkillPresentation.test.ts b/apps/web/src/providerSkillPresentation.test.ts new file mode 100644 index 0000000000..ce94d88a6b --- /dev/null +++ b/apps/web/src/providerSkillPresentation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + formatProviderSkillDisplayName, + formatProviderSkillInstallSource, +} from "./providerSkillPresentation"; + +describe("formatProviderSkillDisplayName", () => { + it("prefers the provider display name", () => { + expect( + formatProviderSkillDisplayName({ + name: "review-follow-up", + displayName: "Review Follow-up", + }), + ).toBe("Review Follow-up"); + }); + + it("falls back to a title-cased skill name", () => { + expect( + formatProviderSkillDisplayName({ + name: "review-follow-up", + }), + ).toBe("Review Follow Up"); + }); +}); + +describe("formatProviderSkillInstallSource", () => { + it("marks plugin-backed skills as app installs", () => { + expect( + formatProviderSkillInstallSource({ + path: "/Users/julius/.codex/plugins/cache/openai-curated/github/skills/gh-fix-ci/SKILL.md", + scope: "user", + }), + ).toBe("App"); + }); + + it("maps standard scopes to user-facing labels", () => { + expect( + formatProviderSkillInstallSource({ + path: "/Users/julius/.agents/skills/agent-browser/SKILL.md", + scope: "user", + }), + ).toBe("Personal"); + expect( + formatProviderSkillInstallSource({ + path: "/usr/local/share/codex/skills/imagegen/SKILL.md", + scope: "system", + }), + ).toBe("System"); + expect( + formatProviderSkillInstallSource({ + path: "/workspace/.codex/skills/review-follow-up/SKILL.md", + scope: "project", + }), + ).toBe("Project"); + }); +}); diff --git a/apps/web/src/providerSkillPresentation.ts b/apps/web/src/providerSkillPresentation.ts new file mode 100644 index 0000000000..fe077cbb19 --- /dev/null +++ b/apps/web/src/providerSkillPresentation.ts @@ -0,0 +1,52 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; + +function titleCaseWords(value: string): string { + return value + .split(/[\s:_-]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function normalizePathSeparators(pathValue: string): string { + return pathValue.replaceAll("\\", "/"); +} + +export function formatProviderSkillDisplayName( + skill: Pick, +): string { + const displayName = skill.displayName?.trim(); + if (displayName) { + return displayName; + } + return titleCaseWords(skill.name); +} + +export function formatProviderSkillInstallSource( + skill: Pick, +): string | null { + const normalizedPath = normalizePathSeparators(skill.path); + if (normalizedPath.includes("/.codex/plugins/") || normalizedPath.includes("/.agents/plugins/")) { + return "App"; + } + + const normalizedScope = skill.scope?.trim().toLowerCase(); + if (normalizedScope === "system") { + return "System"; + } + if ( + normalizedScope === "project" || + normalizedScope === "workspace" || + normalizedScope === "local" + ) { + return "Project"; + } + if (normalizedScope === "user" || normalizedScope === "personal") { + return "Personal"; + } + if (normalizedScope) { + return titleCaseWords(normalizedScope); + } + + return null; +} diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 5ee6d6807f..06179f9858 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -48,6 +48,8 @@ const defaultProviders: ReadonlyArray = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], + slashCommands: [], + skills: [], }, ]; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index a4e33c990b..fb64d6e1ac 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -58,6 +58,24 @@ export const ServerProviderModel = Schema.Struct({ }); export type ServerProviderModel = typeof ServerProviderModel.Type; +export const ServerProviderSlashCommand = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + argumentHint: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerProviderSlashCommand = typeof ServerProviderSlashCommand.Type; + +export const ServerProviderSkill = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + path: TrimmedNonEmptyString, + scope: Schema.optional(TrimmedNonEmptyString), + enabled: Schema.Boolean, + displayName: Schema.optional(TrimmedNonEmptyString), + shortDescription: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerProviderSkill = typeof ServerProviderSkill.Type; + export const ServerProvider = Schema.Struct({ provider: ProviderKind, enabled: Schema.Boolean, @@ -68,6 +86,8 @@ export const ServerProvider = Schema.Struct({ checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + slashCommands: Schema.Array(ServerProviderSlashCommand), + skills: Schema.Array(ServerProviderSkill), }); export type ServerProvider = typeof ServerProvider.Type; From 867bab83c9533846524435142ca0c03c1aadd66f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 15:48:55 -0700 Subject: [PATCH 2/9] Propagate slash command input hints - Map provider slash command hints into the shared contract - Surface provider command metadata in the chat composer --- .../src/provider/Layers/ClaudeProvider.ts | 35 ++++++++----------- .../provider/Layers/ProviderRegistry.test.ts | 2 ++ apps/web/src/components/chat/ChatComposer.tsx | 7 ++-- .../components/chat/ComposerCommandMenu.tsx | 12 ++++--- packages/contracts/src/server.ts | 7 +++- 5 files changed, 31 insertions(+), 32 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index c918d0cf3a..039ce41397 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -10,7 +10,10 @@ import type { import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk"; +import { + query as claudeQuery, + type SlashCommand as ClaudeSlashCommand, +} from "@anthropic-ai/claude-agent-sdk"; import { buildServerProvider, @@ -388,38 +391,28 @@ export function adjustModelsForSubscription( const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; -function readProbeString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function nonEmptyProbeString(value: unknown): string | undefined { - const candidate = readProbeString(value)?.trim(); +function nonEmptyProbeString(value: string): string | undefined { + const candidate = value.trim(); return candidate ? candidate : undefined; } function parseClaudeInitializationCommands( - commands: unknown, + commands: ReadonlyArray | undefined, ): ReadonlyArray { - return (Array.isArray(commands) ? commands : []).flatMap((value) => { - if (!value || typeof value !== "object") { - return []; - } - - const record = value as Record; - const name = nonEmptyProbeString(record.name); + return (commands ?? []).flatMap((command) => { + const name = nonEmptyProbeString(command.name); if (!name) { return []; } + const description = nonEmptyProbeString(command.description); + const argumentHint = nonEmptyProbeString(command.argumentHint); + return [ { name, - ...(nonEmptyProbeString(record.description) - ? { description: nonEmptyProbeString(record.description) } - : {}), - ...(nonEmptyProbeString(record.argumentHint) - ? { argumentHint: nonEmptyProbeString(record.argumentHint) } - : {}), + ...(description ? { description } : {}), + ...(argumentHint ? { input: { hint: argumentHint } } : {}), } satisfies ServerProviderSlashCommand, ]; }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 54c65a2893..f53e730c34 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -942,6 +942,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( { name: "review", description: "Review a pull request", + input: { hint: "pr-or-branch" }, }, ]), ); @@ -950,6 +951,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( { name: "review", description: "Review a pull request", + input: { hint: "pr-or-branch" }, }, ]); }).pipe( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bd416b3d5a..f27afa3ea2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -737,12 +737,9 @@ export const ChatComposer = memo( id: `provider-slash-command:${selectedProvider}:${command.name}`, type: "provider-slash-command" as const, provider: selectedProvider, - command: { - name: command.name, - ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), - }, + command, label: `/${command.name}`, - description: command.description ?? command.argumentHint ?? "Run provider command", + description: command.description ?? command.input?.hint ?? "Run provider command", }), ); const query = composerTrigger.query.trim().toLowerCase(); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 007fd7c2ed..7390d790ff 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,4 +1,9 @@ -import { type ProjectEntry, type ProviderKind, type ServerProviderSkill } from "@t3tools/contracts"; +import { + type ProjectEntry, + type ProviderKind, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; import { BotIcon } from "lucide-react"; import { memo, useLayoutEffect, useMemo, useRef } from "react"; @@ -36,10 +41,7 @@ export type ComposerCommandItem = id: string; type: "provider-slash-command"; provider: ProviderKind; - command: { - name: string; - argumentHint?: string; - }; + command: ServerProviderSlashCommand; label: string; description: string; } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index fb64d6e1ac..383ff6329e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -58,10 +58,15 @@ export const ServerProviderModel = Schema.Struct({ }); export type ServerProviderModel = typeof ServerProviderModel.Type; +export const ServerProviderSlashCommandInput = Schema.Struct({ + hint: TrimmedNonEmptyString, +}); +export type ServerProviderSlashCommandInput = typeof ServerProviderSlashCommandInput.Type; + export const ServerProviderSlashCommand = Schema.Struct({ name: TrimmedNonEmptyString, description: Schema.optional(TrimmedNonEmptyString), - argumentHint: Schema.optional(TrimmedNonEmptyString), + input: Schema.optional(ServerProviderSlashCommandInput), }); export type ServerProviderSlashCommand = typeof ServerProviderSlashCommand.Type; From 26dfbbe0ac43410d0b43e8fe02cf1b3f397eae4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 22:56:46 +0000 Subject: [PATCH 3/9] fix: gracefully handle skills/list RPC errors instead of rejecting entire probe When skills/list returns an error (e.g. on older Codex CLI versions that don't support it), fall back to an empty skills array instead of calling fail() which would reject the entire discovery promise and break account probing. --- apps/server/src/provider/codexAppServer.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index de028a9213..9f51beeef9 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -205,12 +205,7 @@ export async function probeCodexDiscovery(input: { if (response.id === 2) { const errorMessage = readErrorMessage(response); - if (errorMessage) { - fail(new Error(`skills/list failed: ${errorMessage}`)); - return; - } - - skills = parseCodexSkillsResult(response.result, input.cwd); + skills = errorMessage ? [] : parseCodexSkillsResult(response.result, input.cwd); maybeResolve(); return; } From 9c6129956681af1b56b58e7d5459e4a51569d408 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 16:07:25 -0700 Subject: [PATCH 4/9] Preserve composer skill state and simplify menu refs - Restore serialized composer skill node state - Sync skill labels before paint to avoid stale UI - Keep composer menu refs current during render --- apps/server/src/provider/codexAppServer.ts | 13 ------------- apps/web/src/components/ComposerPromptEditor.tsx | 4 ++-- apps/web/src/components/chat/ChatComposer.tsx | 8 +++----- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index de028a9213..6e6f70c46c 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -244,16 +244,3 @@ export async function probeCodexDiscovery(input: { }); }); } - -export async function probeCodexAccount(input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly signal?: AbortSignal; -}): Promise { - return ( - await probeCodexDiscovery({ - ...input, - cwd: process.cwd(), - }) - ).account; -} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 8c79535990..1fd55b7d6c 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -237,7 +237,7 @@ class ComposerSkillNode extends TextNode { return $createComposerSkillNode( serializedNode.skillName, serializedNode.skillLabel ?? serializedNode.skillName, - ); + ).updateFromJSON(serializedNode); } constructor(skillName: string, skillLabel: string, key?: NodeKey) { @@ -1401,7 +1401,7 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); - useEffect(() => { + useLayoutEffect(() => { skillsByNameRef.current = skillLabelByName(skills); }, [skills]); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f27afa3ea2..9039298a22 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -817,11 +817,9 @@ export const ChatComposer = memo( [composerHighlightedItemId, composerMenuItems], ); - useEffect(() => { - composerMenuOpenRef.current = composerMenuOpen; - composerMenuItemsRef.current = composerMenuItems; - activeComposerMenuItemRef.current = activeComposerMenuItem; - }, [activeComposerMenuItem, composerMenuItems, composerMenuOpen]); + composerMenuOpenRef.current = composerMenuOpen; + composerMenuItemsRef.current = composerMenuItems; + activeComposerMenuItemRef.current = activeComposerMenuItem; const nonPersistedComposerImageIdSet = useMemo( () => new Set(nonPersistedComposerImageIds), From 1a0b6d8b85bf766c64bb17dcb2f87beafb15e111 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 16:22:35 -0700 Subject: [PATCH 5/9] refactor: enhance workspace entry search ranking and add tests - Introduced a new scoring mechanism for workspace entries that prioritizes exact basename matches over broader path matches. - Updated the search logic to utilize shared ranking functions for improved performance and maintainability. - Added a test case to validate the new ranking behavior for exact matches in workspace entries. - Removed deprecated functions related to scoring and ranking to streamline the codebase. --- .../workspace/Layers/WorkspaceEntries.test.ts | 12 ++ .../src/workspace/Layers/WorkspaceEntries.ts | 159 ++++----------- apps/web/src/components/chat/ChatComposer.tsx | 39 ++-- apps/web/src/providerSkillSearch.test.ts | 59 ++++++ apps/web/src/providerSkillSearch.ts | 105 ++++++++++ packages/shared/package.json | 4 + packages/shared/src/searchRanking.test.ts | 95 +++++++++ packages/shared/src/searchRanking.ts | 187 ++++++++++++++++++ 8 files changed, 517 insertions(+), 143 deletions(-) create mode 100644 apps/web/src/providerSkillSearch.test.ts create mode 100644 apps/web/src/providerSkillSearch.ts create mode 100644 packages/shared/src/searchRanking.test.ts create mode 100644 packages/shared/src/searchRanking.ts diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 960cb69bf1..09f6905ce9 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -129,6 +129,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); + it.effect("prioritizes exact basename matches ahead of broader path matches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" }); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "docs/composer.tsx-notes.md"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 }); + + expect(result.entries[0]?.path).toBe("src/components/Composer.tsx"); + }), + ); + it.effect("tracks truncation without sorting every fuzzy match", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 783333a49e..c4d3c3c81f 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -4,6 +4,12 @@ import type { Dirent } from "node:fs"; import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; import { type ProjectEntry } from "@t3tools/contracts"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, + type RankedSearchResult, +} from "@t3tools/shared/searchRanking"; import { GitCore } from "../../git/Services/GitCore.ts"; import { @@ -40,10 +46,7 @@ interface SearchableWorkspaceEntry extends ProjectEntry { normalizedName: string; } -interface RankedWorkspaceEntry { - entry: SearchableWorkspaceEntry; - score: number; -} +type RankedWorkspaceEntry = RankedSearchResult; function toPosixPath(input: string): string { return input.replaceAll("\\", "/"); @@ -74,45 +77,6 @@ function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEnt }; } -function normalizeQuery(input: string): string { - return input - .trim() - .replace(/^[@./]+/, "") - .toLowerCase(); -} - -function scoreSubsequenceMatch(value: string, query: string): number | null { - if (!query) return 0; - - let queryIndex = 0; - let firstMatchIndex = -1; - let previousMatchIndex = -1; - let gapPenalty = 0; - - for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { - if (value[valueIndex] !== query[queryIndex]) { - continue; - } - - if (firstMatchIndex === -1) { - firstMatchIndex = valueIndex; - } - if (previousMatchIndex !== -1) { - gapPenalty += valueIndex - previousMatchIndex - 1; - } - - previousMatchIndex = valueIndex; - queryIndex += 1; - if (queryIndex === query.length) { - const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; - const lengthPenalty = Math.min(64, value.length - query.length); - return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; - } - } - - return null; -} - function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { if (!query) { return entry.kind === "directory" ? 0 : 1; @@ -120,81 +84,32 @@ function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | nu const { normalizedPath, normalizedName } = entry; - if (normalizedName === query) return 0; - if (normalizedPath === query) return 1; - if (normalizedName.startsWith(query)) return 2; - if (normalizedPath.startsWith(query)) return 3; - if (normalizedPath.includes(`/${query}`)) return 4; - if (normalizedName.includes(query)) return 5; - if (normalizedPath.includes(query)) return 6; - - const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); - if (nameFuzzyScore !== null) { - return 100 + nameFuzzyScore; - } - - const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); - if (pathFuzzyScore !== null) { - return 200 + pathFuzzyScore; + const scores = [ + scoreQueryMatch({ + value: normalizedName, + query, + exactBase: 0, + prefixBase: 2, + includesBase: 5, + fuzzyBase: 100, + }), + scoreQueryMatch({ + value: normalizedPath, + query, + exactBase: 1, + prefixBase: 3, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 200, + boundaryMarkers: ["/"], + }), + ].filter((score): score is number => score !== null); + + if (scores.length === 0) { + return null; } - return null; -} - -function compareRankedWorkspaceEntries( - left: RankedWorkspaceEntry, - right: RankedWorkspaceEntry, -): number { - const scoreDelta = left.score - right.score; - if (scoreDelta !== 0) return scoreDelta; - return left.entry.path.localeCompare(right.entry.path); -} - -function findInsertionIndex( - rankedEntries: RankedWorkspaceEntry[], - candidate: RankedWorkspaceEntry, -): number { - let low = 0; - let high = rankedEntries.length; - - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - const current = rankedEntries[middle]; - if (!current) { - break; - } - - if (compareRankedWorkspaceEntries(candidate, current) < 0) { - high = middle; - } else { - low = middle + 1; - } - } - - return low; -} - -function insertRankedEntry( - rankedEntries: RankedWorkspaceEntry[], - candidate: RankedWorkspaceEntry, - limit: number, -): void { - if (limit <= 0) { - return; - } - - const insertionIndex = findInsertionIndex(rankedEntries, candidate); - if (rankedEntries.length < limit) { - rankedEntries.splice(insertionIndex, 0, candidate); - return; - } - - if (insertionIndex >= limit) { - return; - } - - rankedEntries.splice(insertionIndex, 0, candidate); - rankedEntries.pop(); + return Math.min(...scores); } function isPathInIgnoredDirectory(relativePath: string): boolean { @@ -469,7 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( Effect.map((index) => { - const normalizedQuery = normalizeQuery(input.query); + const normalizedQuery = normalizeSearchQuery(input.query, { + trimLeadingPattern: /^[@./]+/, + }); const limit = Math.max(0, Math.floor(input.limit)); const rankedEntries: RankedWorkspaceEntry[] = []; let matchedEntryCount = 0; @@ -481,11 +398,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () { } matchedEntryCount += 1; - insertRankedEntry(rankedEntries, { entry, score }, limit); + insertRankedSearchResult( + rankedEntries, + { item: entry, score, tieBreaker: entry.path }, + limit, + ); } return { - entries: rankedEntries.map((candidate) => candidate.entry), + entries: rankedEntries.map((candidate) => candidate.item), truncated: index.truncated || matchedEntryCount > limit, }; }), diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9039298a22..26666e39a8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -101,6 +101,7 @@ import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; +import { searchProviderSkills } from "../../providerSkillSearch"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -757,30 +758,20 @@ export const ChatComposer = memo( ); } if (composerTrigger.kind === "skill") { - const query = composerTrigger.query.trim().toLowerCase(); - return (selectedProviderStatus?.skills ?? []) - .filter((skill) => skill.enabled) - .filter((skill) => { - if (!query) return true; - return ( - skill.name.toLowerCase().includes(query) || - skill.displayName?.toLowerCase().includes(query) || - skill.shortDescription?.toLowerCase().includes(query) || - skill.description?.toLowerCase().includes(query) || - skill.scope?.toLowerCase().includes(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( + 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 searchableModelOptions .filter(({ searchSlug, searchName, searchProvider }) => { diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts new file mode 100644 index 0000000000..ede929c8d3 --- /dev/null +++ b/apps/web/src/providerSkillSearch.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; + +import { searchProviderSkills } from "./providerSkillSearch"; + +function makeSkill(input: Partial & Pick) { + return { + path: `/tmp/${input.name}/SKILL.md`, + enabled: true, + ...input, + } satisfies ServerProviderSkill; +} + +describe("searchProviderSkills", () => { + it("moves exact ui matches ahead of broader ui matches", () => { + const skills = [ + makeSkill({ + name: "agent-browser", + displayName: "Agent Browser", + shortDescription: "Browser automation CLI for AI agents", + }), + makeSkill({ + name: "building-native-ui", + displayName: "Building Native Ui", + shortDescription: "Complete guide for building beautiful apps with Expo Router", + }), + makeSkill({ + name: "ui", + displayName: "Ui", + shortDescription: "Explore, build, and refine UI.", + }), + ]; + + expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([ + "ui", + "building-native-ui", + ]); + }); + + it("uses fuzzy ranking for abbreviated queries", () => { + const skills = [ + makeSkill({ name: "gh-fix-ci", displayName: "Gh Fix Ci" }), + makeSkill({ name: "github", displayName: "Github" }), + makeSkill({ name: "agent-browser", displayName: "Agent Browser" }), + ]; + + expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]); + }); + + it("omits disabled skills from results", () => { + const skills = [ + makeSkill({ name: "ui", displayName: "Ui", enabled: false }), + makeSkill({ name: "frontend-design", displayName: "Frontend Design" }), + ]; + + expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]); + }); +}); diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts new file mode 100644 index 0000000000..2391e81813 --- /dev/null +++ b/apps/web/src/providerSkillSearch.ts @@ -0,0 +1,105 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; + +import { formatProviderSkillDisplayName } from "./providerSkillPresentation"; + +function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null { + const normalizedName = skill.name.toLowerCase(); + const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase(); + const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? ""; + const normalizedDescription = skill.description?.toLowerCase() ?? ""; + const normalizedScope = skill.scope?.toLowerCase() ?? ""; + + const scores = [ + scoreQueryMatch({ + value: normalizedName, + query, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: normalizedLabel, + query, + exactBase: 1, + prefixBase: 3, + boundaryBase: 5, + includesBase: 7, + fuzzyBase: 110, + }), + scoreQueryMatch({ + value: normalizedShortDescription, + query, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + scoreQueryMatch({ + value: normalizedDescription, + query, + exactBase: 30, + prefixBase: 32, + boundaryBase: 34, + includesBase: 36, + }), + scoreQueryMatch({ + value: normalizedScope, + query, + exactBase: 40, + prefixBase: 42, + includesBase: 44, + }), + ].filter((score): score is number => score !== null); + + if (scores.length === 0) { + return null; + } + + return Math.min(...scores); +} + +export function searchProviderSkills( + skills: ReadonlyArray, + query: string, + limit = Number.POSITIVE_INFINITY, +): ServerProviderSkill[] { + const enabledSkills = skills.filter((skill) => skill.enabled); + const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); + + if (!normalizedQuery) { + return enabledSkills; + } + + const ranked: Array<{ + item: ServerProviderSkill; + score: number; + tieBreaker: string; + }> = []; + + for (const skill of enabledSkills) { + const score = scoreProviderSkill(skill, normalizedQuery); + if (score === null) { + continue; + } + + insertRankedSearchResult( + ranked, + { + item: skill, + score, + tieBreaker: `${formatProviderSkillDisplayName(skill).toLowerCase()}\u0000${skill.name}`, + }, + limit, + ); + } + + return ranked.map((entry) => entry.item); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index bc103dab71..ed65cbeaf3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -52,6 +52,10 @@ "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" }, + "./searchRanking": { + "types": "./src/searchRanking.ts", + "import": "./src/searchRanking.ts" + }, "./qrCode": { "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts new file mode 100644 index 0000000000..d8c4b3d6ca --- /dev/null +++ b/packages/shared/src/searchRanking.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { + compareRankedSearchResults, + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, + scoreSubsequenceMatch, +} from "./searchRanking"; + +describe("normalizeSearchQuery", () => { + it("trims and lowercases queries", () => { + expect(normalizeSearchQuery(" UI ")).toBe("ui"); + }); + + it("can strip leading trigger characters", () => { + expect(normalizeSearchQuery(" $ui", { trimLeadingPattern: /^\$+/ })).toBe("ui"); + }); +}); + +describe("scoreQueryMatch", () => { + it("prefers exact matches over broader contains matches", () => { + expect( + scoreQueryMatch({ + value: "ui", + query: "ui", + exactBase: 0, + prefixBase: 10, + includesBase: 20, + }), + ).toBe(0); + + expect( + scoreQueryMatch({ + value: "building native ui", + query: "ui", + exactBase: 0, + prefixBase: 10, + boundaryBase: 20, + includesBase: 30, + }), + ).toBeGreaterThan(0); + }); + + it("treats boundary matches as stronger than generic contains matches", () => { + const boundaryScore = scoreQueryMatch({ + value: "gh-fix-ci", + query: "fix", + exactBase: 0, + prefixBase: 10, + boundaryBase: 20, + includesBase: 30, + boundaryMarkers: ["-"], + }); + const containsScore = scoreQueryMatch({ + value: "highfixci", + query: "fix", + exactBase: 0, + prefixBase: 10, + boundaryBase: 20, + includesBase: 30, + boundaryMarkers: ["-"], + }); + + expect(boundaryScore).not.toBeNull(); + expect(containsScore).not.toBeNull(); + expect(boundaryScore!).toBeLessThan(containsScore!); + }); +}); + +describe("scoreSubsequenceMatch", () => { + it("scores tighter subsequences ahead of looser ones", () => { + const compact = scoreSubsequenceMatch("ghfixci", "gfc"); + const spread = scoreSubsequenceMatch("github-fix-ci", "gfc"); + + expect(compact).not.toBeNull(); + expect(spread).not.toBeNull(); + expect(compact!).toBeLessThan(spread!); + }); +}); + +describe("insertRankedSearchResult", () => { + it("keeps the best-ranked candidates within the limit", () => { + const ranked = [ + { item: "b", score: 20, tieBreaker: "b" }, + { item: "d", score: 40, tieBreaker: "d" }, + ]; + + insertRankedSearchResult(ranked, { item: "a", score: 10, tieBreaker: "a" }, 2); + insertRankedSearchResult(ranked, { item: "c", score: 30, tieBreaker: "c" }, 2); + + expect(ranked.map((entry) => entry.item)).toEqual(["a", "b"]); + expect(compareRankedSearchResults(ranked[0]!, ranked[1]!)).toBeLessThan(0); + }); +}); diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts new file mode 100644 index 0000000000..2f7f3bfb00 --- /dev/null +++ b/packages/shared/src/searchRanking.ts @@ -0,0 +1,187 @@ +export type RankedSearchResult = { + item: T; + score: number; + tieBreaker: string; +}; + +export function normalizeSearchQuery( + input: string, + options?: { + trimLeadingPattern?: RegExp; + }, +): string { + const trimmed = input.trim(); + if (!trimmed) { + return ""; + } + return options?.trimLeadingPattern + ? trimmed.replace(options.trimLeadingPattern, "").toLowerCase() + : trimmed.toLowerCase(); +} + +export function scoreSubsequenceMatch(value: string, query: string): number | null { + if (!query) return 0; + + let queryIndex = 0; + let firstMatchIndex = -1; + let previousMatchIndex = -1; + let gapPenalty = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { + if (value[valueIndex] !== query[queryIndex]) { + continue; + } + + if (firstMatchIndex === -1) { + firstMatchIndex = valueIndex; + } + if (previousMatchIndex !== -1) { + gapPenalty += valueIndex - previousMatchIndex - 1; + } + + previousMatchIndex = valueIndex; + queryIndex += 1; + if (queryIndex === query.length) { + const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; + const lengthPenalty = Math.min(64, value.length - query.length); + return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; + } + } + + return null; +} + +function lengthPenalty(value: string, query: string): number { + return Math.min(64, Math.max(0, value.length - query.length)); +} + +function findBoundaryMatchIndex( + value: string, + query: string, + boundaryMarkers: readonly string[], +): number | null { + let bestIndex: number | null = null; + + for (const marker of boundaryMarkers) { + const index = value.indexOf(`${marker}${query}`); + if (index === -1) { + continue; + } + + const matchIndex = index + marker.length; + if (bestIndex === null || matchIndex < bestIndex) { + bestIndex = matchIndex; + } + } + + return bestIndex; +} + +export function scoreQueryMatch(input: { + value: string; + query: string; + exactBase: number; + prefixBase?: number; + boundaryBase?: number; + includesBase?: number; + fuzzyBase?: number; + boundaryMarkers?: readonly string[]; +}): number | null { + const value = input.value.trim().toLowerCase(); + const query = input.query.trim().toLowerCase(); + + if (!value || !query) { + return null; + } + + if (value === query) { + return input.exactBase; + } + + if (input.prefixBase !== undefined && value.startsWith(query)) { + return input.prefixBase + lengthPenalty(value, query); + } + + if (input.boundaryBase !== undefined) { + const boundaryIndex = findBoundaryMatchIndex( + value, + query, + input.boundaryMarkers ?? [" ", "-", "_", "/"], + ); + if (boundaryIndex !== null) { + return input.boundaryBase + boundaryIndex * 2 + lengthPenalty(value, query); + } + } + + if (input.includesBase !== undefined) { + const includesIndex = value.indexOf(query); + if (includesIndex !== -1) { + return input.includesBase + includesIndex * 2 + lengthPenalty(value, query); + } + } + + if (input.fuzzyBase !== undefined) { + const fuzzyScore = scoreSubsequenceMatch(value, query); + if (fuzzyScore !== null) { + return input.fuzzyBase + fuzzyScore; + } + } + + return null; +} + +export function compareRankedSearchResults( + left: RankedSearchResult, + right: RankedSearchResult, +): number { + const scoreDelta = left.score - right.score; + if (scoreDelta !== 0) return scoreDelta; + return left.tieBreaker.localeCompare(right.tieBreaker); +} + +function findInsertionIndex( + rankedEntries: RankedSearchResult[], + candidate: RankedSearchResult, +): number { + let low = 0; + let high = rankedEntries.length; + + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const current = rankedEntries[middle]; + if (!current) { + break; + } + + if (compareRankedSearchResults(candidate, current) < 0) { + high = middle; + } else { + low = middle + 1; + } + } + + return low; +} + +export function insertRankedSearchResult( + rankedEntries: RankedSearchResult[], + candidate: RankedSearchResult, + limit: number, +): void { + if (limit <= 0) { + return; + } + + const insertionIndex = findInsertionIndex(rankedEntries, candidate); + if (rankedEntries.length < limit) { + rankedEntries.splice(insertionIndex, 0, candidate); + return; + } + + if (insertionIndex >= limit) { + return; + } + + rankedEntries.splice(insertionIndex, 0, candidate); + rankedEntries.pop(); +} From e89f114d235f2fcc08792f31bd5d160627c16d6a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 16:29:41 -0700 Subject: [PATCH 6/9] include settings sources for claude --- apps/server/src/provider/Layers/ClaudeProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 1181f7e4a4..b36f7f498d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -392,7 +392,7 @@ const probeClaudeCapabilities = (binaryPath: string) => { pathToClaudeCodeExecutable: binaryPath, abortController: abort, maxTurns: 0, - settingSources: [], + settingSources: ["user", "project", "local"], allowedTools: [], stderr: () => {}, }, From b0488e2c2eee8183bf2ec6d0aa6dde4d03a66348 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 16:40:15 -0700 Subject: [PATCH 7/9] Improve provider slash command search and dedupe Claude commands Co-authored-by: codex --- .../src/provider/Layers/ClaudeProvider.ts | 72 ++++++++++++---- .../provider/Layers/ProviderRegistry.test.ts | 41 +++++++++ apps/web/src/components/chat/ChatComposer.tsx | 18 ++-- .../components/chat/ComposerCommandMenu.tsx | 9 +- .../chat/composerSlashCommandSearch.test.ts | 68 +++++++++++++++ .../chat/composerSlashCommandSearch.ts | 83 +++++++++++++++++++ 6 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/chat/composerSlashCommandSearch.test.ts create mode 100644 apps/web/src/components/chat/composerSlashCommandSearch.ts diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b36f7f498d..d00be86d3e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -352,23 +352,64 @@ function nonEmptyProbeString(value: string): string | undefined { function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { - return (commands ?? []).flatMap((command) => { + return dedupeSlashCommands( + (commands ?? []).flatMap((command) => { + const name = nonEmptyProbeString(command.name); + if (!name) { + return []; + } + + const description = nonEmptyProbeString(command.description); + const argumentHint = nonEmptyProbeString(command.argumentHint); + + return [ + { + name, + ...(description ? { description } : {}), + ...(argumentHint ? { input: { hint: argumentHint } } : {}), + } satisfies ServerProviderSlashCommand, + ]; + }), + ); +} + +function dedupeSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const commandsByName = new Map(); + + for (const command of commands) { const name = nonEmptyProbeString(command.name); if (!name) { - return []; + continue; } - const description = nonEmptyProbeString(command.description); - const argumentHint = nonEmptyProbeString(command.argumentHint); - - return [ - { + const key = name.toLowerCase(); + const existing = commandsByName.get(key); + if (!existing) { + commandsByName.set(key, { + ...command, name, - ...(description ? { description } : {}), - ...(argumentHint ? { input: { hint: argumentHint } } : {}), - } satisfies ServerProviderSlashCommand, - ]; - }); + }); + continue; + } + + commandsByName.set(key, { + ...existing, + ...(existing.description + ? {} + : command.description + ? { description: command.description } + : {}), + ...(existing.input?.hint + ? {} + : command.input?.hint + ? { input: { hint: command.input.hint } } + : {}), + }); + } + + return [...commandsByName.values()]; } /** @@ -534,6 +575,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( Effect.orElseSucceed(() => undefined), ) : undefined) ?? []; + const dedupedSlashCommands = dedupeSlashCommands(slashCommands); // ── Auth check + subscription detection ──────────────────────────── @@ -569,7 +611,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, - slashCommands, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -589,7 +631,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, - slashCommands, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -607,7 +649,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, - slashCommands, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 38c0558da3..9c12048e45 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -972,6 +972,47 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("deduplicates probed claude slash commands by name", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + () => + Effect.succeed([ + { + name: "ui", + description: "Explore and refine UI", + }, + { + name: "ui", + input: { hint: "component-or-screen" }, + }, + ]), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "ui", + description: "Explore and refine UI", + input: { hint: "component-or-screen" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 26666e39a8..7c392ec607 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -69,6 +69,7 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { getComposerProviderState, renderProviderTraitsMenuContent, @@ -748,14 +749,7 @@ export const ChatComposer = memo( if (!query) { return slashCommandItems; } - return slashCommandItems.filter( - (item) => - item.label.slice(1).toLowerCase().includes(query) || - item.description.toLowerCase().includes(query) || - (item.type === "slash-command" - ? item.command.includes(query) - : item.command.name.toLowerCase().includes(query)), - ); + return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { return searchProviderSkills( @@ -868,7 +862,7 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ const setPromptFromTraits = useCallback( (nextPrompt: string) => { - if (nextPrompt === prompt) { + if (nextPrompt === promptRef.current) { scheduleComposerFocus(); return; } @@ -879,7 +873,7 @@ export const ChatComposer = memo( setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, prompt, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -1763,6 +1757,10 @@ export const ChatComposer = memo( resolvedTheme={resolvedTheme} isLoading={isComposerMenuLoading} triggerKind={composerTriggerKind} + groupSlashCommandSections={ + composerTrigger?.kind === "slash-command" && + composerTrigger.query.trim().length === 0 + } emptyStateText={composerMenuEmptyState} activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlighted} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 7390d790ff..de7cf2b2b8 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -90,11 +90,12 @@ function SkillGlyph(props: { className?: string }) { function groupCommandItems( items: ComposerCommandItem[], triggerKind: ComposerTriggerKind | null, + groupSlashCommandSections: boolean, ): ComposerCommandGroup[] { if (triggerKind === "skill") { return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : []; } - if (triggerKind !== "slash-command") { + if (triggerKind !== "slash-command" || !groupSlashCommandSections) { return [{ id: "default", label: null, items }]; } @@ -116,6 +117,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; + groupSlashCommandSections?: boolean; emptyStateText?: string; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; @@ -123,8 +125,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { }) { const listRef = useRef(null); const groups = useMemo( - () => groupCommandItems(props.items, props.triggerKind), - [props.items, props.triggerKind], + () => + groupCommandItems(props.items, props.triggerKind, props.groupSlashCommandSections ?? true), + [props.groupSlashCommandSections, props.items, props.triggerKind], ); useLayoutEffect(() => { diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts new file mode 100644 index 0000000000..3da69933d0 --- /dev/null +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import type { ComposerCommandItem } from "./ComposerCommandMenu"; +import { searchSlashCommandItems } from "./composerSlashCommandSearch"; + +describe("searchSlashCommandItems", () => { + it("moves exact provider command matches ahead of broader description matches", () => { + const items = [ + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + { + id: "provider-slash-command:claudeAgent:ui", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "ui" }, + label: "/ui", + description: "Explore, build, and refine UI.", + }, + { + id: "provider-slash-command:claudeAgent:frontend-design", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "frontend-design" }, + label: "/frontend-design", + description: "Create distinctive, production-grade frontend interfaces", + }, + ] satisfies Array< + Extract + >; + + expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([ + "provider-slash-command:claudeAgent:ui", + "slash:default", + ]); + }); + + it("supports fuzzy provider command matches", () => { + const items = [ + { + id: "provider-slash-command:claudeAgent:gh-fix-ci", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "gh-fix-ci" }, + label: "/gh-fix-ci", + description: "Fix failing GitHub Actions", + }, + { + id: "provider-slash-command:claudeAgent:github", + type: "provider-slash-command", + provider: "claudeAgent", + command: { name: "github" }, + label: "/github", + description: "General GitHub help", + }, + ] satisfies Array< + Extract + >; + + expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([ + "provider-slash-command:claudeAgent:gh-fix-ci", + ]); + }); +}); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts new file mode 100644 index 0000000000..c4919b1924 --- /dev/null +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -0,0 +1,83 @@ +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; + +import type { ComposerCommandItem } from "./ComposerCommandMenu"; + +function scoreSlashCommandItem( + item: Extract, + query: string, +): number | null { + const primaryValue = + item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); + const description = item.description.toLowerCase(); + + const scores = [ + scoreQueryMatch({ + value: primaryValue, + query, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: description, + query, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + ].filter((score): score is number => score !== null); + + if (scores.length === 0) { + return null; + } + + return Math.min(...scores); +} + +export function searchSlashCommandItems( + items: ReadonlyArray< + Extract + >, + query: string, +): Array> { + const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); + if (!normalizedQuery) { + return [...items]; + } + + const ranked: Array<{ + item: Extract; + score: number; + tieBreaker: string; + }> = []; + + for (const item of items) { + const score = scoreSlashCommandItem(item, normalizedQuery); + if (score === null) { + continue; + } + + insertRankedSearchResult( + ranked, + { + item, + score, + tieBreaker: + item.type === "slash-command" + ? `0\u0000${item.command}` + : `1\u0000${item.command.name}\u0000${item.provider}`, + }, + Number.POSITIVE_INFINITY, + ); + } + + return ranked.map((entry) => entry.item); +} From 20f5a4b15c41c4aa54f4a8008836e358a8cf4ecb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 23:54:17 +0000 Subject: [PATCH 8/9] Remove redundant trim/toLowerCase in scoreQueryMatch All callers already pass pre-normalized (trimmed and lowercased) inputs, so the internal .trim().toLowerCase() calls were redundant O(n) string operations allocating unnecessary copies on every candidate per keystroke. Replace with a destructured read and a JSDoc contract stating that inputs must be pre-normalized. --- packages/shared/src/searchRanking.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index 2f7f3bfb00..b2fb2e223d 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -77,6 +77,12 @@ function findBoundaryMatchIndex( return bestIndex; } +/** + * Scores how well `value` matches `query` using tiered match strategies. + * + * **Expects pre-normalized inputs**: both `value` and `query` must already be + * trimmed and lowercased (e.g. via {@link normalizeSearchQuery}). + */ export function scoreQueryMatch(input: { value: string; query: string; @@ -87,8 +93,7 @@ export function scoreQueryMatch(input: { fuzzyBase?: number; boundaryMarkers?: readonly string[]; }): number | null { - const value = input.value.trim().toLowerCase(); - const query = input.query.trim().toLowerCase(); + const { value, query } = input; if (!value || !query) { return null; From 7debd9c151e177d2aadf582bc3095f6c19be7811 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 17:09:16 -0700 Subject: [PATCH 9/9] Improve skill composer UX and snapshot compatibility Co-authored-by: codex --- apps/web/src/components/ChatView.browser.tsx | 51 +++++ .../src/components/ComposerPromptEditor.tsx | 178 +++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 60 ++++-- .../chat/composerMenuHighlight.test.ts | 51 +++++ .../components/chat/composerMenuHighlight.ts | 20 ++ packages/contracts/src/server.test.ts | 26 +++ packages/contracts/src/server.ts | 8 +- 7 files changed, 312 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/components/chat/composerMenuHighlight.test.ts create mode 100644 apps/web/src/components/chat/composerMenuHighlight.ts create mode 100644 packages/contracts/src/server.test.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 381ca68e5b..c64e6ac99e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3805,4 +3805,55 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("shows a tooltip with the skill description when hovering a skill pill", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-skill-tooltip-target" as MessageId, + targetText: "skill tooltip thread", + }), + configureFixture: (nextFixture) => { + const provider = nextFixture.serverConfig.providers[0]; + if (!provider) { + throw new Error("Expected default provider in test fixture."); + } + ( + provider as { + skills: ServerConfig["providers"][number]["skills"]; + } + ).skills = [ + { + name: "agent-browser", + displayName: "Agent Browser", + description: "Open pages, click around, and inspect web apps.", + path: "/Users/test/.agents/skills/agent-browser/SKILL.md", + enabled: true, + }, + ]; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "use the $agent-browser "); + await waitForComposerText("use the $agent-browser "); + + await waitForElement( + () => document.querySelector('[data-composer-skill-chip="true"]'), + "Unable to find rendered composer skill chip.", + ); + await page.getByText("Agent Browser").hover(); + + await vi.waitFor( + () => { + const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); + expect(tooltip).not.toBeNull(); + expect(tooltip?.textContent).toContain("Open pages, click around, and inspect web apps."); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 1fd55b7d6c..95157d787f 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -78,6 +78,7 @@ import { } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; const SURROUND_SYMBOLS: [string, string][] = [ @@ -109,10 +110,11 @@ type SerializedComposerSkillNode = Spread< { skillName: string; skillLabel?: string; + skillDescription?: string; type: "composer-skill"; version: 1; }, - SerializedTextNode + SerializedLexicalNode >; type SerializedComposerTerminalContextNode = Spread< @@ -204,47 +206,104 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { const SKILL_CHIP_ICON_SVG = ``; -function renderSkillChipDom(container: HTMLElement, skillLabel: string): void { - container.textContent = ""; - container.style.setProperty("user-select", "none"); - container.style.setProperty("-webkit-user-select", "none"); +function resolveSkillDescription( + skill: Pick, +): string | null { + const shortDescription = skill.shortDescription?.trim(); + if (shortDescription) { + return shortDescription; + } + const description = skill.description?.trim(); + return description || null; +} - const icon = document.createElement("span"); - icon.ariaHidden = "true"; - icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; - icon.innerHTML = SKILL_CHIP_ICON_SVG; +type ComposerSkillMetadata = { + label: string; + description: string | null; +}; + +function skillMetadataByName( + skills: ReadonlyArray, +): ReadonlyMap { + return new Map( + skills.map((skill) => [ + skill.name, + { + label: formatProviderSkillDisplayName(skill), + description: resolveSkillDescription(skill), + }, + ]), + ); +} - const label = document.createElement("span"); - label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; - label.textContent = skillLabel; +function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: string | null }) { + const chip = ( + + + ); - container.append(icon, label); + if (!props.skillDescription) { + return chip; + } + + return ( + + + + {props.skillDescription} + + + ); } -class ComposerSkillNode extends TextNode { +class ComposerSkillNode extends DecoratorNode { __skillName: string; __skillLabel: string; + __skillDescription: string | null; static override getType(): string { return "composer-skill"; } static override clone(node: ComposerSkillNode): ComposerSkillNode { - return new ComposerSkillNode(node.__skillName, node.__skillLabel, node.__key); + return new ComposerSkillNode( + node.__skillName, + node.__skillLabel, + node.__skillDescription, + node.__key, + ); } static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { return $createComposerSkillNode( serializedNode.skillName, serializedNode.skillLabel ?? serializedNode.skillName, + serializedNode.skillDescription ?? null, ).updateFromJSON(serializedNode); } - constructor(skillName: string, skillLabel: string, key?: NodeKey) { + constructor( + skillName: string, + skillLabel: string, + skillDescription: string | null, + key?: NodeKey, + ) { + super(key); const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; - super(`$${normalizedSkillName}`, key); this.__skillName = normalizedSkillName; this.__skillLabel = skillLabel; + this.__skillDescription = skillDescription; } override exportJSON(): SerializedComposerSkillNode { @@ -252,56 +311,46 @@ class ComposerSkillNode extends TextNode { ...super.exportJSON(), skillName: this.__skillName, skillLabel: this.__skillLabel, + ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), type: "composer-skill", version: 1, }; } - override createDOM(_config: EditorConfig): HTMLElement { + override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - dom.setAttribute("spellcheck", "false"); - renderSkillChipDom(dom, this.__skillLabel); + dom.className = "inline-flex align-middle leading-none"; return dom; } - override updateDOM( - prevNode: ComposerSkillNode, - dom: HTMLElement, - _config: EditorConfig, - ): boolean { - dom.className = COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - if ( - prevNode.__text !== this.__text || - prevNode.__skillName !== this.__skillName || - prevNode.__skillLabel !== this.__skillLabel - ) { - renderSkillChipDom(dom, this.__skillLabel); - } - return false; - } - - override canInsertTextBefore(): false { + override updateDOM(): false { return false; } - override canInsertTextAfter(): false { - return false; + override getTextContent(): string { + return `$${this.__skillName}`; } - override isTextEntity(): true { + override isInline(): true { return true; } - override isToken(): true { - return true; + override decorate(): ReactElement { + return ( + + ); } } -function $createComposerSkillNode(skillName: string, skillLabel: string): ComposerSkillNode { - return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel)); +function $createComposerSkillNode( + skillName: string, + skillLabel: string, + skillDescription: string | null, +): ComposerSkillNode { + return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); } function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { @@ -429,6 +478,7 @@ function skillSignature(skills: ReadonlyArray): string { skill.name, skill.displayName ?? "", skill.shortDescription ?? "", + skill.description ?? "", skill.path, skill.scope ?? "", skill.enabled ? "1" : "0", @@ -437,10 +487,6 @@ function skillSignature(skills: ReadonlyArray): string { .join("\u001e"); } -function skillLabelByName(skills: ReadonlyArray): ReadonlyMap { - return new Map(skills.map((skill) => [skill.name, formatProviderSkillDisplayName(skill)])); -} - function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); @@ -549,12 +595,12 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { + if (node instanceof ComposerMentionNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerTerminalContextNode) { + if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -596,12 +642,12 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { + if (node instanceof ComposerMentionNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerTerminalContextNode) { + if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -796,7 +842,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, - skillsByName: ReadonlyMap, + skillMetadata: ReadonlyMap, ): void { const root = $getRoot(); root.clear(); @@ -810,10 +856,12 @@ function $setComposerEditorPrompt( continue; } if (segment.type === "skill") { + const metadata = skillMetadata.get(segment.name); paragraph.append( $createComposerSkillNode( segment.name, - skillsByName.get(segment.name) ?? formatProviderSkillDisplayName({ name: segment.name }), + metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), + metadata?.description ?? null, ), ); continue; @@ -1100,7 +1148,7 @@ function ComposerSurroundSelectionPlugin(props: { }) { const [editor] = useLexicalComposerContext(); const terminalContextsRef = useRef(props.terminalContexts); - const skillsByNameRef = useRef(skillLabelByName(props.skills)); + const skillMetadataRef = useRef(skillMetadataByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ value: string; expandedStart: number; @@ -1117,7 +1165,7 @@ function ComposerSurroundSelectionPlugin(props: { }, [props.terminalContexts]); useEffect(() => { - skillsByNameRef.current = skillLabelByName(props.skills); + skillMetadataRef.current = skillMetadataByName(props.skills); }, [props.skills]); const applySurroundInsertion = useCallback( @@ -1165,7 +1213,7 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillsByNameRef.current); + $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, @@ -1384,7 +1432,7 @@ function ComposerPromptEditorInner({ const terminalContextsSignatureRef = useRef(terminalContextsSignature); const skillsSignature = skillSignature(skills); const skillsSignatureRef = useRef(skillsSignature); - const skillsByNameRef = useRef(skillLabelByName(skills)); + const skillMetadataRef = useRef(skillMetadataByName(skills)); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -1402,7 +1450,7 @@ function ComposerPromptEditorInner({ }, [onChange]); useLayoutEffect(() => { - skillsByNameRef.current = skillLabelByName(skills); + skillMetadataRef.current = skillMetadataByName(skills); }, [skills]); useEffect(() => { @@ -1443,7 +1491,7 @@ function ComposerPromptEditorInner({ const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged || skillsChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, skillsByNameRef.current); + $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1641,7 +1689,7 @@ export const ComposerPromptEditor = forwardRef< ) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); - const initialSkillsByNameRef = useRef(skillLabelByName(skills)); + const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", @@ -1651,7 +1699,7 @@ export const ComposerPromptEditor = forwardRef< $setComposerEditorPrompt( initialValueRef.current, initialTerminalContextsRef.current, - initialSkillsByNameRef.current, + initialSkillMetadataRef.current, ); }, onError: (error) => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7c392ec607..f73389d8a0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -69,6 +69,7 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { getComposerProviderState, @@ -643,6 +644,9 @@ export const ChatComposer = memo( detectComposerTrigger(prompt, prompt.length), ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( + null, + ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); @@ -794,13 +798,23 @@ export const ChatComposer = memo( ]); const composerMenuOpen = Boolean(composerTrigger); - const activeComposerMenuItem = useMemo( - () => - composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? - composerMenuItems[0] ?? - null, - [composerHighlightedItemId, composerMenuItems], - ); + const composerMenuSearchKey = composerTrigger + ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` + : null; + const activeComposerMenuItem = useMemo(() => { + const activeItemId = resolveComposerMenuActiveItemId({ + items: composerMenuItems, + highlightedItemId: composerHighlightedItemId, + currentSearchKey: composerMenuSearchKey, + highlightedSearchKey: composerHighlightedSearchKey, + }); + return composerMenuItems.find((item) => item.id === activeItemId) ?? null; + }, [ + composerHighlightedItemId, + composerHighlightedSearchKey, + composerMenuItems, + composerMenuSearchKey, + ]); composerMenuOpenRef.current = composerMenuOpen; composerMenuItemsRef.current = composerMenuItems; @@ -986,14 +1000,28 @@ export const ChatComposer = memo( useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); + setComposerHighlightedSearchKey(null); return; } + const nextActiveItemId = resolveComposerMenuActiveItemId({ + items: composerMenuItems, + highlightedItemId: composerHighlightedItemId, + currentSearchKey: composerMenuSearchKey, + highlightedSearchKey: composerHighlightedSearchKey, + }); setComposerHighlightedItemId((existing) => - existing && composerMenuItems.some((item) => item.id === existing) - ? existing - : (composerMenuItems[0]?.id ?? null), + existing === nextActiveItemId ? existing : nextActiveItemId, + ); + setComposerHighlightedSearchKey((existing) => + existing === composerMenuSearchKey ? existing : composerMenuSearchKey, ); - }, [composerMenuItems, composerMenuOpen]); + }, [ + composerHighlightedItemId, + composerHighlightedSearchKey, + composerMenuItems, + composerMenuOpen, + composerMenuSearchKey, + ]); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; @@ -1430,9 +1458,13 @@ export const ChatComposer = memo( ], ); - const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { - setComposerHighlightedItemId(itemId); - }, []); + const onComposerMenuItemHighlighted = useCallback( + (itemId: string | null) => { + setComposerHighlightedItemId(itemId); + setComposerHighlightedSearchKey(composerMenuSearchKey); + }, + [composerMenuSearchKey], + ); const nudgeComposerMenuHighlight = useCallback( (key: "ArrowDown" | "ArrowUp") => { diff --git a/apps/web/src/components/chat/composerMenuHighlight.test.ts b/apps/web/src/components/chat/composerMenuHighlight.test.ts new file mode 100644 index 0000000000..08c0f2f24d --- /dev/null +++ b/apps/web/src/components/chat/composerMenuHighlight.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; + +describe("resolveComposerMenuActiveItemId", () => { + const items = [{ id: "top" }, { id: "second" }, { id: "third" }] as const; + + it("defaults to the first item when nothing is highlighted", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: null, + currentSearchKey: "skill:u", + highlightedSearchKey: null, + }), + ).toBe("top"); + }); + + it("preserves the highlighted item within the same query", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "second", + currentSearchKey: "skill:u", + highlightedSearchKey: "skill:u", + }), + ).toBe("second"); + }); + + it("resets to the top result when the query changes", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "second", + currentSearchKey: "skill:ui", + highlightedSearchKey: "skill:u", + }), + ).toBe("top"); + }); + + it("falls back to the first item when the highlighted item disappears", () => { + expect( + resolveComposerMenuActiveItemId({ + items, + highlightedItemId: "missing", + currentSearchKey: "skill:ui", + highlightedSearchKey: "skill:ui", + }), + ).toBe("top"); + }); +}); diff --git a/apps/web/src/components/chat/composerMenuHighlight.ts b/apps/web/src/components/chat/composerMenuHighlight.ts new file mode 100644 index 0000000000..3cc3d4324f --- /dev/null +++ b/apps/web/src/components/chat/composerMenuHighlight.ts @@ -0,0 +1,20 @@ +export function resolveComposerMenuActiveItemId(input: { + items: ReadonlyArray<{ id: string }>; + highlightedItemId: string | null; + currentSearchKey: string | null; + highlightedSearchKey: string | null; +}): string | null { + if (input.items.length === 0) { + return null; + } + + if ( + input.currentSearchKey === input.highlightedSearchKey && + input.highlightedItemId && + input.items.some((item) => item.id === input.highlightedItemId) + ) { + return input.highlightedItemId; + } + + return input.items[0]?.id ?? null; +} diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts new file mode 100644 index 0000000000..6e5f70c2e4 --- /dev/null +++ b/packages/contracts/src/server.test.ts @@ -0,0 +1,26 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { ServerProvider } from "./server"; + +const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); + +describe("ServerProvider", () => { + it("defaults capability arrays when decoding legacy snapshots", () => { + const parsed = decodeServerProvider({ + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + }); + + expect(parsed.slashCommands).toEqual([]); + expect(parsed.skills).toEqual([]); + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 383ff6329e..50db737c6a 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Effect, Schema } from "effect"; import { ExecutionEnvironmentDescriptor } from "./environment"; import { ServerAuthDescriptor } from "./auth"; import { @@ -91,8 +91,10 @@ export const ServerProvider = Schema.Struct({ checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), - slashCommands: Schema.Array(ServerProviderSlashCommand), - skills: Schema.Array(ServerProviderSkill), + slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }); export type ServerProvider = typeof ServerProvider.Type;