diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 2d8f09d27b..d00be86d3e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,12 +4,16 @@ import type { ServerProvider, ServerProviderModel, ServerProviderAuth, + ServerProviderSlashCommand, ServerProviderState, } from "@t3tools/contracts"; 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, @@ -340,6 +344,74 @@ function claudeAuthMetadata(input: { const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +function nonEmptyProbeString(value: string): string | undefined { + const candidate = value.trim(); + return candidate ? candidate : undefined; +} + +function parseClaudeInitializationCommands( + commands: ReadonlyArray | undefined, +): ReadonlyArray { + 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) { + continue; + } + + const key = name.toLowerCase(); + const existing = commandsByName.get(key); + if (!existing) { + commandsByName.set(key, { + ...command, + name, + }); + 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()]; +} + /** * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. @@ -361,13 +433,16 @@ const probeClaudeCapabilities = (binaryPath: string) => { pathToClaudeCodeExecutable: binaryPath, abortController: abort, maxTurns: 0, - settingSources: [], + settingSources: ["user", "project", "local"], allowedTools: [], stderr: () => {}, }, }); const init = await q.initializationResult(); - return { subscriptionType: init.account?.subscriptionType }; + return { + subscriptionType: init.account?.subscriptionType, + slashCommands: parseClaudeInitializationCommands(init.commands), + }; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -396,6 +471,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, @@ -491,6 +569,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const slashCommands = + (resolveSlashCommands + ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( + Effect.orElseSucceed(() => undefined), + ) + : undefined) ?? []; + const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + // ── Auth check + subscription detection ──────────────────────────── const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( @@ -525,6 +611,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -544,6 +631,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -561,6 +649,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + slashCommands: dedupedSlashCommands, probe: { installed: true, version: parsedVersion, @@ -583,12 +672,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 085abe6fe2..421621c969 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, @@ -500,6 +518,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -518,6 +537,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu enabled: codexSettings.enabled, checkedAt, models: resolvedModels, + skills, probe: { installed: true, version: parsedVersion, @@ -543,16 +563,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 3d6f418603..9c12048e45 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -221,6 +221,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(); @@ -499,6 +542,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", @@ -509,6 +554,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; @@ -887,6 +934,85 @@ 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", + input: { hint: "pr-or-branch" }, + }, + ]), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]); + }).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("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/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index d25fc3533e..7b3c9eeb79 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,27 @@ 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); + skills = errorMessage ? [] : 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(); } }); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 232d2d3582..40246563ae 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"; @@ -130,6 +132,8 @@ export function buildServerProvider(input: { enabled: boolean; checkedAt: string; models: ReadonlyArray; + slashCommands?: ReadonlyArray; + skills?: ReadonlyArray; probe: ProviderProbeResult; }): ServerProvider { return { @@ -142,6 +146,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/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/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bfb2b95ba2..c64e6ac99e 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: [], @@ -3803,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 430efffa21..95157d787f 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,11 @@ 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"; +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][] = [ @@ -102,6 +106,17 @@ type SerializedComposerMentionNode = Spread< SerializedTextNode >; +type SerializedComposerSkillNode = Spread< + { + skillName: string; + skillLabel?: string; + skillDescription?: string; + type: "composer-skill"; + version: 1; + }, + SerializedLexicalNode +>; + type SerializedComposerTerminalContextNode = Spread< { context: TerminalContextDraft; @@ -189,6 +204,155 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { return $applyNodeReplacement(new ComposerMentionNode(path)); } +const SKILL_CHIP_ICON_SVG = ``; + +function resolveSkillDescription( + skill: Pick, +): string | null { + const shortDescription = skill.shortDescription?.trim(); + if (shortDescription) { + return shortDescription; + } + const description = skill.description?.trim(); + return description || null; +} + +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), + }, + ]), + ); +} + +function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: string | null }) { + const chip = ( + + + ); + + if (!props.skillDescription) { + return chip; + } + + return ( + + + + {props.skillDescription} + + + ); +} + +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.__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, + skillDescription: string | null, + key?: NodeKey, + ) { + super(key); + const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; + this.__skillName = normalizedSkillName; + this.__skillLabel = skillLabel; + this.__skillDescription = skillDescription; + } + + override exportJSON(): SerializedComposerSkillNode { + return { + ...super.exportJSON(), + skillName: this.__skillName, + skillLabel: this.__skillLabel, + ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), + type: "composer-skill", + version: 1, + }; + } + + override createDOM(): HTMLElement { + const dom = document.createElement("span"); + dom.className = "inline-flex align-middle leading-none"; + return dom; + } + + override updateDOM(): false { + return false; + } + + override getTextContent(): string { + return `$${this.__skillName}`; + } + + override isInline(): true { + return true; + } + + override decorate(): ReactElement { + return ( + + ); + } +} + +function $createComposerSkillNode( + skillName: string, + skillLabel: string, + skillDescription: string | null, +): ComposerSkillNode { + return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); +} + function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } @@ -253,11 +417,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 +471,22 @@ function terminalContextSignature(contexts: ReadonlyArray) .join("\u001e"); } +function skillSignature(skills: ReadonlyArray): string { + return skills + .map((skill) => + [ + skill.name, + skill.displayName ?? "", + skill.shortDescription ?? "", + skill.description ?? "", + skill.path, + skill.scope ?? "", + skill.enabled ? "1" : "0", + ].join("\u001f"), + ) + .join("\u001e"); +} + function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); @@ -415,7 +600,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerTerminalContextNode) { + if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -462,7 +647,7 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerTerminalContextNode) { + if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -488,7 +673,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 +842,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, + skillMetadata: ReadonlyMap, ): void { const root = $getRoot(); root.clear(); @@ -669,6 +855,17 @@ function $setComposerEditorPrompt( paragraph.append($createComposerMentionNode(segment.path)); continue; } + if (segment.type === "skill") { + const metadata = skillMetadata.get(segment.name); + paragraph.append( + $createComposerSkillNode( + segment.name, + metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), + metadata?.description ?? null, + ), + ); + continue; + } if (segment.type === "terminal-context") { if (segment.context) { paragraph.append($createComposerTerminalContextNode(segment.context)); @@ -705,6 +902,7 @@ interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; + skills: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; @@ -946,9 +1144,11 @@ function ComposerInlineTokenBackspacePlugin() { function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; + skills: ReadonlyArray; }) { const [editor] = useLexicalComposerContext(); const terminalContextsRef = useRef(props.terminalContexts); + const skillMetadataRef = useRef(skillMetadataByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ value: string; expandedStart: number; @@ -964,6 +1164,10 @@ function ComposerSurroundSelectionPlugin(props: { terminalContextsRef.current = props.terminalContexts; }, [props.terminalContexts]); + useEffect(() => { + skillMetadataRef.current = skillMetadataByName(props.skills); + }, [props.skills]); + const applySurroundInsertion = useCallback( (inputData: string): boolean => { const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); @@ -1009,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); + $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, @@ -1211,6 +1415,7 @@ function ComposerPromptEditorInner({ value, cursor, terminalContexts, + skills, disabled, placeholder, className, @@ -1225,6 +1430,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 skillMetadataRef = useRef(skillMetadataByName(skills)); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -1241,6 +1449,10 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); + useLayoutEffect(() => { + skillMetadataRef.current = skillMetadataByName(skills); + }, [skills]); + useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); @@ -1249,10 +1461,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 +1478,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, skillMetadataRef.current); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1284,7 +1500,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 +1658,7 @@ function ComposerPromptEditorInner({ /> - + @@ -1460,6 +1676,7 @@ export const ComposerPromptEditor = forwardRef< value, cursor, terminalContexts, + skills, disabled, placeholder, className, @@ -1472,13 +1689,18 @@ export const ComposerPromptEditor = forwardRef< ) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); + const initialSkillMetadataRef = useRef(skillMetadataByName(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, + initialSkillMetadataRef.current, + ); }, onError: (error) => { throw error; @@ -1493,6 +1715,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 32f2f68e4a..223f5d8ebd 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..f73389d8a0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -69,6 +69,8 @@ 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, renderProviderTraitsMenuContent, @@ -100,6 +102,8 @@ 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"; +import { searchProviderSkills } from "../../providerSkillSearch"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -560,6 +564,10 @@ export const ChatComposer = memo( }); const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); + const selectedProviderStatus = useMemo( + () => providerStatuses.find((provider) => provider.provider === selectedProvider), + [providerStatuses, selectedProvider], + ); const composerProviderState = useMemo( () => @@ -636,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); @@ -704,7 +715,7 @@ export const ChatComposer = memo( })); } if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ + const builtInSlashCommandItems = [ { id: "slash:model", type: "slash-command", @@ -727,13 +738,38 @@ 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, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "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), - ); + return searchSlashCommandItems(slashCommandItems, query); + } + if (composerTrigger.kind === "skill") { + return searchProviderSkills( + selectedProviderStatus?.skills ?? [], + composerTrigger.query, + ).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return searchableModelOptions .filter(({ searchSlug, searchName, searchProvider }) => { @@ -753,16 +789,32 @@ export const ChatComposer = memo( label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [ + composerTrigger, + searchableModelOptions, + selectedProvider, + selectedProviderStatus, + workspaceEntries, + ]); 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; @@ -810,14 +862,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 === promptRef.current) { scheduleComposerFocus(); return; } @@ -941,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, ); - }, [composerMenuItems, composerMenuOpen]); + setComposerHighlightedSearchKey((existing) => + existing === composerMenuSearchKey ? existing : composerMenuSearchKey, + ); + }, [ + composerHighlightedItemId, + composerHighlightedSearchKey, + composerMenuItems, + composerMenuOpen, + composerMenuSearchKey, + ]); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; @@ -1333,6 +1406,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), @@ -1349,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") => { @@ -1676,6 +1789,11 @@ 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} onSelect={onSelectComposerItem} @@ -1765,6 +1883,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..de7cf2b2b8 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,10 +1,24 @@ -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, + type ServerProviderSlashCommand, +} 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 +37,14 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "provider-slash-command"; + provider: ProviderKind; + command: ServerProviderSlashCommand; + label: string; + description: string; + } | { id: string; type: "model"; @@ -30,18 +52,83 @@ 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, + groupSlashCommandSections: boolean, +): ComposerCommandGroup[] { + if (triggerKind === "skill") { + return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : []; + } + if (triggerKind !== "slash-command" || !groupSlashCommandSections) { + 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; + groupSlashCommandSections?: boolean; + 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.groupSlashCommandSections ?? true), + [props.groupSlashCommandSections, props.items, props.triggerKind], + ); useLayoutEffect(() => { if (!props.activeItemId || !listRef.current) return; @@ -65,27 +152,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 +214,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 087d8bca51..686a36b60d 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/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/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); +} 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 68047f4495..3883d77c8d 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/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/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 4df3c6927d..a587fcd9f1 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.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 a4e33c990b..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 { @@ -58,6 +58,29 @@ 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), + input: Schema.optional(ServerProviderSlashCommandInput), +}); +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 +91,10 @@ export const ServerProvider = Schema.Struct({ checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }); export type ServerProvider = typeof ServerProvider.Type; 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..b2fb2e223d --- /dev/null +++ b/packages/shared/src/searchRanking.ts @@ -0,0 +1,192 @@ +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; +} + +/** + * 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; + exactBase: number; + prefixBase?: number; + boundaryBase?: number; + includesBase?: number; + fuzzyBase?: number; + boundaryMarkers?: readonly string[]; +}): number | null { + const { value, query } = input; + + 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(); +}