authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-10 17:19:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 17:19:23-07:00
log58e5f714b03ec44b42f00b52947a73d991fb8d8a
tree1b9067715b78694d19813f3120ee345485446fea
parent4ae9de311b4627f6cf0ce00a67172cb9a3fb7c1d
signature Signed by PGP key B5690EEEBB952194

Add provider skill discovery (#1905)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

34 files changed, 2062 insertions(+), 230 deletions(-)

apps/server/src/provider/Layers/ClaudeProvider.ts+102-7
...@@ -4,12 +4,16 @@ import type {...@@ -4,12 +4,16 @@ import type {
4 ServerProvider,4 ServerProvider,
5 ServerProviderModel,5 ServerProviderModel,
6 ServerProviderAuth,6 ServerProviderAuth,
7 ServerProviderSlashCommand,
7 ServerProviderState,8 ServerProviderState,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";10import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { decodeJsonResult } from "@t3tools/shared/schemaJson";12import { decodeJsonResult } from "@t3tools/shared/schemaJson";
12import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk";13import {
14 query as claudeQuery,
15 type SlashCommand as ClaudeSlashCommand,
16} from "@anthropic-ai/claude-agent-sdk";
1317
14import {18import {
15 buildServerProvider,19 buildServerProvider,
...@@ -340,6 +344,74 @@ function claudeAuthMetadata(input: {...@@ -340,6 +344,74 @@ function claudeAuthMetadata(input: {
340344
341const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000;345const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000;
342346
347function nonEmptyProbeString(value: string): string | undefined {
348 const candidate = value.trim();
349 return candidate ? candidate : undefined;
350}
351
352function parseClaudeInitializationCommands(
353 commands: ReadonlyArray<ClaudeSlashCommand> | undefined,
354): ReadonlyArray<ServerProviderSlashCommand> {
355 return dedupeSlashCommands(
356 (commands ?? []).flatMap((command) => {
357 const name = nonEmptyProbeString(command.name);
358 if (!name) {
359 return [];
360 }
361
362 const description = nonEmptyProbeString(command.description);
363 const argumentHint = nonEmptyProbeString(command.argumentHint);
364
365 return [
366 {
367 name,
368 ...(description ? { description } : {}),
369 ...(argumentHint ? { input: { hint: argumentHint } } : {}),
370 } satisfies ServerProviderSlashCommand,
371 ];
372 }),
373 );
374}
375
376function dedupeSlashCommands(
377 commands: ReadonlyArray<ServerProviderSlashCommand>,
378): ReadonlyArray<ServerProviderSlashCommand> {
379 const commandsByName = new Map<string, ServerProviderSlashCommand>();
380
381 for (const command of commands) {
382 const name = nonEmptyProbeString(command.name);
383 if (!name) {
384 continue;
385 }
386
387 const key = name.toLowerCase();
388 const existing = commandsByName.get(key);
389 if (!existing) {
390 commandsByName.set(key, {
391 ...command,
392 name,
393 });
394 continue;
395 }
396
397 commandsByName.set(key, {
398 ...existing,
399 ...(existing.description
400 ? {}
401 : command.description
402 ? { description: command.description }
403 : {}),
404 ...(existing.input?.hint
405 ? {}
406 : command.input?.hint
407 ? { input: { hint: command.input.hint } }
408 : {}),
409 });
410 }
411
412 return [...commandsByName.values()];
413}
414
343/**415/**
344 * Probe account information by spawning a lightweight Claude Agent SDK416 * Probe account information by spawning a lightweight Claude Agent SDK
345 * session and reading the initialization result.417 * session and reading the initialization result.
...@@ -361,13 +433,16 @@ const probeClaudeCapabilities = (binaryPath: string) => {...@@ -361,13 +433,16 @@ const probeClaudeCapabilities = (binaryPath: string) => {
361 pathToClaudeCodeExecutable: binaryPath,433 pathToClaudeCodeExecutable: binaryPath,
362 abortController: abort,434 abortController: abort,
363 maxTurns: 0,435 maxTurns: 0,
364 settingSources: [],436 settingSources: ["user", "project", "local"],
365 allowedTools: [],437 allowedTools: [],
366 stderr: () => {},438 stderr: () => {},
367 },439 },
368 });440 });
369 const init = await q.initializationResult();441 const init = await q.initializationResult();
370 return { subscriptionType: init.account?.subscriptionType };442 return {
443 subscriptionType: init.account?.subscriptionType,
444 slashCommands: parseClaudeInitializationCommands(init.commands),
445 };
371 }).pipe(446 }).pipe(
372 Effect.ensuring(447 Effect.ensuring(
373 Effect.sync(() => {448 Effect.sync(() => {
...@@ -396,6 +471,9 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: Readonly...@@ -396,6 +471,9 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: Readonly
396471
397export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (472export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
398 resolveSubscriptionType?: (binaryPath: string) => Effect.Effect<string | undefined>,473 resolveSubscriptionType?: (binaryPath: string) => Effect.Effect<string | undefined>,
474 resolveSlashCommands?: (
475 binaryPath: string,
476 ) => Effect.Effect<ReadonlyArray<ServerProviderSlashCommand> | undefined>,
399): Effect.fn.Return<477): Effect.fn.Return<
400 ServerProvider,478 ServerProvider,
401 ServerSettingsError,479 ServerSettingsError,
...@@ -491,6 +569,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -491,6 +569,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
491 });569 });
492 }570 }
493571
572 const slashCommands =
573 (resolveSlashCommands
574 ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe(
575 Effect.orElseSucceed(() => undefined),
576 )
577 : undefined) ?? [];
578 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
579
494 // ── Auth check + subscription detection ────────────────────────────580 // ── Auth check + subscription detection ────────────────────────────
495581
496 const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(582 const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
...@@ -525,6 +611,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -525,6 +611,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
525 enabled: claudeSettings.enabled,611 enabled: claudeSettings.enabled,
526 checkedAt,612 checkedAt,
527 models,613 models,
614 slashCommands: dedupedSlashCommands,
528 probe: {615 probe: {
529 installed: true,616 installed: true,
530 version: parsedVersion,617 version: parsedVersion,
...@@ -544,6 +631,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -544,6 +631,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
544 enabled: claudeSettings.enabled,631 enabled: claudeSettings.enabled,
545 checkedAt,632 checkedAt,
546 models,633 models,
634 slashCommands: dedupedSlashCommands,
547 probe: {635 probe: {
548 installed: true,636 installed: true,
549 version: parsedVersion,637 version: parsedVersion,
...@@ -561,6 +649,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -561,6 +649,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
561 enabled: claudeSettings.enabled,649 enabled: claudeSettings.enabled,
562 checkedAt,650 checkedAt,
563 models,651 models,
652 slashCommands: dedupedSlashCommands,
564 probe: {653 probe: {
565 installed: true,654 installed: true,
566 version: parsedVersion,655 version: parsedVersion,
...@@ -583,12 +672,18 @@ export const ClaudeProviderLive = Layer.effect(...@@ -583,12 +672,18 @@ export const ClaudeProviderLive = Layer.effect(
583 const subscriptionProbeCache = yield* Cache.make({672 const subscriptionProbeCache = yield* Cache.make({
584 capacity: 1,673 capacity: 1,
585 timeToLive: Duration.minutes(5),674 timeToLive: Duration.minutes(5),
586 lookup: (binaryPath: string) =>675 lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath),
587 probeClaudeCapabilities(binaryPath).pipe(Effect.map((r) => r?.subscriptionType)),
588 });676 });
589677
590 const checkProvider = checkClaudeProviderStatus((binaryPath) =>678 const checkProvider = checkClaudeProviderStatus(
591 Cache.get(subscriptionProbeCache, binaryPath),679 (binaryPath) =>
680 Cache.get(subscriptionProbeCache, binaryPath).pipe(
681 Effect.map((probe) => probe?.subscriptionType),
682 ),
683 (binaryPath) =>
684 Cache.get(subscriptionProbeCache, binaryPath).pipe(
685 Effect.map((probe) => probe?.slashCommands),
686 ),
592 ).pipe(687 ).pipe(
593 Effect.provideService(ServerSettingsService, serverSettings),688 Effect.provideService(ServerSettingsService, serverSettings),
594 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),689 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
apps/server/src/provider/Layers/CodexProvider.ts+38-5
...@@ -5,6 +5,7 @@ import type {...@@ -5,6 +5,7 @@ import type {
5 ServerProvider,5 ServerProvider,
6 ServerProviderModel,6 ServerProviderModel,
7 ServerProviderAuth,7 ServerProviderAuth,
8 ServerProviderSkill,
8 ServerProviderState,9 ServerProviderState,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import {11import {
...@@ -44,7 +45,7 @@ import {...@@ -44,7 +45,7 @@ import {
44 codexAuthSubType,45 codexAuthSubType,
45 type CodexAccountSnapshot,46 type CodexAccountSnapshot,
46} from "../codexAccount";47} from "../codexAccount";
47import { probeCodexAccount } from "../codexAppServer";48import { probeCodexDiscovery } from "../codexAppServer";
48import { CodexProvider } from "../Services/CodexProvider";49import { CodexProvider } from "../Services/CodexProvider";
49import { ServerSettingsService } from "../../serverSettings";50import { ServerSettingsService } from "../../serverSettings";
50import { ServerSettingsError } from "@t3tools/contracts";51import { ServerSettingsError } from "@t3tools/contracts";
...@@ -304,8 +305,9 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000;...@@ -304,8 +305,9 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000;
304const probeCodexCapabilities = (input: {305const probeCodexCapabilities = (input: {
305 readonly binaryPath: string;306 readonly binaryPath: string;
306 readonly homePath?: string;307 readonly homePath?: string;
308 readonly cwd: string;
307}) =>309}) =>
308 Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal })).pipe(310 Effect.tryPromise((signal) => probeCodexDiscovery({ ...input, signal })).pipe(
309 Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),311 Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
310 Effect.result,312 Effect.result,
311 Effect.map((result) => {313 Effect.map((result) => {
...@@ -334,6 +336,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -334,6 +336,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
334 readonly binaryPath: string;336 readonly binaryPath: string;
335 readonly homePath?: string;337 readonly homePath?: string;
336 }) => Effect.Effect<CodexAccountSnapshot | undefined>,338 }) => Effect.Effect<CodexAccountSnapshot | undefined>,
339 resolveSkills?: (input: {
340 readonly binaryPath: string;
341 readonly homePath?: string;
342 readonly cwd: string;
343 }) => Effect.Effect<ReadonlyArray<ServerProviderSkill> | undefined>,
337): Effect.fn.Return<344): Effect.fn.Return<
338 ServerProvider,345 ServerProvider,
339 ServerSettingsError,346 ServerSettingsError,
...@@ -449,12 +456,22 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -449,12 +456,22 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
449 });456 });
450 }457 }
451458
459 const skills =
460 (resolveSkills
461 ? yield* resolveSkills({
462 binaryPath: codexSettings.binaryPath,
463 homePath: codexSettings.homePath,
464 cwd: process.cwd(),
465 }).pipe(Effect.orElseSucceed(() => undefined))
466 : undefined) ?? [];
467
452 if (yield* hasCustomModelProvider) {468 if (yield* hasCustomModelProvider) {
453 return buildServerProvider({469 return buildServerProvider({
454 provider: PROVIDER,470 provider: PROVIDER,
455 enabled: codexSettings.enabled,471 enabled: codexSettings.enabled,
456 checkedAt,472 checkedAt,
457 models,473 models,
474 skills,
458 probe: {475 probe: {
459 installed: true,476 installed: true,
460 version: parsedVersion,477 version: parsedVersion,
...@@ -484,6 +501,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -484,6 +501,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
484 enabled: codexSettings.enabled,501 enabled: codexSettings.enabled,
485 checkedAt,502 checkedAt,
486 models: resolvedModels,503 models: resolvedModels,
504 skills,
487 probe: {505 probe: {
488 installed: true,506 installed: true,
489 version: parsedVersion,507 version: parsedVersion,
...@@ -500,6 +518,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -500,6 +518,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
500 enabled: codexSettings.enabled,518 enabled: codexSettings.enabled,
501 checkedAt,519 checkedAt,
502 models: resolvedModels,520 models: resolvedModels,
521 skills,
503 probe: {522 probe: {
504 installed: true,523 installed: true,
505 version: parsedVersion,524 version: parsedVersion,
...@@ -518,6 +537,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -518,6 +537,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
518 enabled: codexSettings.enabled,537 enabled: codexSettings.enabled,
519 checkedAt,538 checkedAt,
520 models: resolvedModels,539 models: resolvedModels,
540 skills,
521 probe: {541 probe: {
522 installed: true,542 installed: true,
523 version: parsedVersion,543 version: parsedVersion,
...@@ -543,16 +563,29 @@ export const CodexProviderLive = Layer.effect(...@@ -543,16 +563,29 @@ export const CodexProviderLive = Layer.effect(
543 capacity: 4,563 capacity: 4,
544 timeToLive: Duration.minutes(5),564 timeToLive: Duration.minutes(5),
545 lookup: (key: string) => {565 lookup: (key: string) => {
546 const [binaryPath, homePath] = JSON.parse(key) as [string, string | undefined];566 const [binaryPath, homePath, cwd] = JSON.parse(key) as [string, string | undefined, string];
547 return probeCodexCapabilities({567 return probeCodexCapabilities({
548 binaryPath,568 binaryPath,
569 cwd,
549 ...(homePath ? { homePath } : {}),570 ...(homePath ? { homePath } : {}),
550 });571 });
551 },572 },
552 });573 });
553574
554 const checkProvider = checkCodexProviderStatus((input) =>575 const getDiscovery = (input: {
555 Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])),576 readonly binaryPath: string;
577 readonly homePath?: string;
578 readonly cwd: string;
579 }) =>
580 Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath, input.cwd]));
581
582 const checkProvider = checkCodexProviderStatus(
583 (input) =>
584 getDiscovery({
585 ...input,
586 cwd: process.cwd(),
587 }).pipe(Effect.map((discovery) => discovery?.account)),
588 (input) => getDiscovery(input).pipe(Effect.map((discovery) => discovery?.skills)),
556 ).pipe(589 ).pipe(
557 Effect.provideService(ServerSettingsService, serverSettings),590 Effect.provideService(ServerSettingsService, serverSettings),
558 Effect.provideService(FileSystem.FileSystem, fileSystem),591 Effect.provideService(FileSystem.FileSystem, fileSystem),
apps/server/src/provider/Layers/ProviderRegistry.test.ts+126
...@@ -221,6 +221,49 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -221,6 +221,49 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
221 ),221 ),
222 );222 );
223223
224 it.effect("includes probed codex skills in the provider snapshot", () =>
225 Effect.gen(function* () {
226 yield* withTempCodexHome();
227 const status = yield* checkCodexProviderStatus(
228 () =>
229 Effect.succeed({
230 type: "chatgpt" as const,
231 planType: "pro" as const,
232 sparkEnabled: true,
233 }),
234 () =>
235 Effect.succeed([
236 {
237 name: "github:gh-fix-ci",
238 path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md",
239 enabled: true,
240 displayName: "CI Debug",
241 shortDescription: "Debug failing GitHub Actions checks",
242 },
243 ]),
244 );
245
246 assert.deepStrictEqual(status.skills, [
247 {
248 name: "github:gh-fix-ci",
249 path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md",
250 enabled: true,
251 displayName: "CI Debug",
252 shortDescription: "Debug failing GitHub Actions checks",
253 },
254 ]);
255 }).pipe(
256 Effect.provide(
257 mockSpawnerLayer((args) => {
258 const joined = args.join(" ");
259 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
260 if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };
261 throw new Error(`Unexpected args: ${joined}`);
262 }),
263 ),
264 ),
265 );
266
224 it.effect("hides spark from codex models for unsupported chatgpt plans", () =>267 it.effect("hides spark from codex models for unsupported chatgpt plans", () =>
225 Effect.gen(function* () {268 Effect.gen(function* () {
226 yield* withTempCodexHome();269 yield* withTempCodexHome();
...@@ -499,6 +542,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -499,6 +542,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
499 checkedAt: "2026-03-25T00:00:00.000Z",542 checkedAt: "2026-03-25T00:00:00.000Z",
500 version: "1.0.0",543 version: "1.0.0",
501 models: [],544 models: [],
545 slashCommands: [],
546 skills: [],
502 },547 },
503 {548 {
504 provider: "claudeAgent",549 provider: "claudeAgent",
...@@ -509,6 +554,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -509,6 +554,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
509 checkedAt: "2026-03-25T00:00:00.000Z",554 checkedAt: "2026-03-25T00:00:00.000Z",
510 version: "1.0.0",555 version: "1.0.0",
511 models: [],556 models: [],
557 slashCommands: [],
558 skills: [],
512 },559 },
513 ] as const satisfies ReadonlyArray<ServerProvider>;560 ] as const satisfies ReadonlyArray<ServerProvider>;
514561
...@@ -887,6 +934,85 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -887,6 +934,85 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
887 ),934 ),
888 );935 );
889936
937 it.effect("includes probed claude slash commands in the provider snapshot", () =>
938 Effect.gen(function* () {
939 const status = yield* checkClaudeProviderStatus(
940 () => Effect.succeed("maxplan"),
941 () =>
942 Effect.succeed([
943 {
944 name: "review",
945 description: "Review a pull request",
946 input: { hint: "pr-or-branch" },
947 },
948 ]),
949 );
950
951 assert.deepStrictEqual(status.slashCommands, [
952 {
953 name: "review",
954 description: "Review a pull request",
955 input: { hint: "pr-or-branch" },
956 },
957 ]);
958 }).pipe(
959 Effect.provide(
960 mockSpawnerLayer((args) => {
961 const joined = args.join(" ");
962 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
963 if (joined === "auth status")
964 return {
965 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
966 stderr: "",
967 code: 0,
968 };
969 throw new Error(`Unexpected args: ${joined}`);
970 }),
971 ),
972 ),
973 );
974
975 it.effect("deduplicates probed claude slash commands by name", () =>
976 Effect.gen(function* () {
977 const status = yield* checkClaudeProviderStatus(
978 () => Effect.succeed("maxplan"),
979 () =>
980 Effect.succeed([
981 {
982 name: "ui",
983 description: "Explore and refine UI",
984 },
985 {
986 name: "ui",
987 input: { hint: "component-or-screen" },
988 },
989 ]),
990 );
991
992 assert.deepStrictEqual(status.slashCommands, [
993 {
994 name: "ui",
995 description: "Explore and refine UI",
996 input: { hint: "component-or-screen" },
997 },
998 ]);
999 }).pipe(
1000 Effect.provide(
1001 mockSpawnerLayer((args) => {
1002 const joined = args.join(" ");
1003 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
1004 if (joined === "auth status")
1005 return {
1006 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
1007 stderr: "",
1008 code: 0,
1009 };
1010 throw new Error(`Unexpected args: ${joined}`);
1011 }),
1012 ),
1013 ),
1014 );
1015
890 it.effect("returns an api key label for claude api key auth", () =>1016 it.effect("returns an api key label for claude api key auth", () =>
891 Effect.gen(function* () {1017 Effect.gen(function* () {
892 const status = yield* checkClaudeProviderStatus();1018 const status = yield* checkClaudeProviderStatus();
apps/server/src/provider/codexAppServer.ts+95-8
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";1import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
2import readline from "node:readline";2import readline from "node:readline";
3import type { ServerProviderSkill } from "@t3tools/contracts";
3import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount";4import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount";
45
5interface JsonRpcProbeResponse {6interface JsonRpcProbeResponse {
...@@ -10,10 +11,74 @@ interface JsonRpcProbeResponse {...@@ -10,10 +11,74 @@ interface JsonRpcProbeResponse {
10 };11 };
11}12}
1213
14export interface CodexDiscoverySnapshot {
15 readonly account: CodexAccountSnapshot;
16 readonly skills: ReadonlyArray<ServerProviderSkill>;
17}
18
13function readErrorMessage(response: JsonRpcProbeResponse): string | undefined {19function readErrorMessage(response: JsonRpcProbeResponse): string | undefined {
14 return typeof response.error?.message === "string" ? response.error.message : undefined;20 return typeof response.error?.message === "string" ? response.error.message : undefined;
15}21}
1622
23function readObject(value: unknown): Record<string, unknown> | undefined {
24 return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
25}
26
27function readArray(value: unknown): ReadonlyArray<unknown> | undefined {
28 return Array.isArray(value) ? value : undefined;
29}
30
31function readString(value: unknown): string | undefined {
32 return typeof value === "string" ? value : undefined;
33}
34
35function nonEmptyTrimmed(value: unknown): string | undefined {
36 const candidate = readString(value)?.trim();
37 return candidate ? candidate : undefined;
38}
39
40function parseCodexSkillsResult(result: unknown, cwd: string): ReadonlyArray<ServerProviderSkill> {
41 const resultRecord = readObject(result);
42 const dataBuckets = readArray(resultRecord?.data) ?? [];
43 const matchingBucket = dataBuckets.find(
44 (value) => nonEmptyTrimmed(readObject(value)?.cwd) === cwd,
45 );
46 const rawSkills =
47 readArray(readObject(matchingBucket)?.skills) ?? readArray(resultRecord?.skills) ?? [];
48
49 return rawSkills.flatMap((value) => {
50 const skill = readObject(value);
51 const display = readObject(skill?.interface);
52 const name = nonEmptyTrimmed(skill?.name);
53 const path = nonEmptyTrimmed(skill?.path);
54 if (!name || !path) {
55 return [];
56 }
57
58 return [
59 {
60 name,
61 path,
62 enabled: skill?.enabled !== false,
63 ...(nonEmptyTrimmed(skill?.description)
64 ? { description: nonEmptyTrimmed(skill?.description) }
65 : {}),
66 ...(nonEmptyTrimmed(skill?.scope) ? { scope: nonEmptyTrimmed(skill?.scope) } : {}),
67 ...(nonEmptyTrimmed(display?.displayName)
68 ? { displayName: nonEmptyTrimmed(display?.displayName) }
69 : {}),
70 ...(nonEmptyTrimmed(skill?.shortDescription) || nonEmptyTrimmed(display?.shortDescription)
71 ? {
72 shortDescription:
73 nonEmptyTrimmed(skill?.shortDescription) ??
74 nonEmptyTrimmed(display?.shortDescription),
75 }
76 : {}),
77 } satisfies ServerProviderSkill,
78 ];
79 });
80}
81
17export function buildCodexInitializeParams() {82export function buildCodexInitializeParams() {
18 return {83 return {
19 clientInfo: {84 clientInfo: {
...@@ -40,11 +105,12 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo...@@ -40,11 +105,12 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo
40 child.kill();105 child.kill();
41}106}
42107
43export async function probeCodexAccount(input: {108export async function probeCodexDiscovery(input: {
44 readonly binaryPath: string;109 readonly binaryPath: string;
45 readonly homePath?: string;110 readonly homePath?: string;
111 readonly cwd: string;
46 readonly signal?: AbortSignal;112 readonly signal?: AbortSignal;
47}): Promise<CodexAccountSnapshot> {113}): Promise<CodexDiscoverySnapshot> {
48 return await new Promise((resolve, reject) => {114 return await new Promise((resolve, reject) => {
49 const child = spawn(input.binaryPath, ["app-server"], {115 const child = spawn(input.binaryPath, ["app-server"], {
50 env: {116 env: {
...@@ -57,6 +123,8 @@ export async function probeCodexAccount(input: {...@@ -57,6 +123,8 @@ export async function probeCodexAccount(input: {
57 const output = readline.createInterface({ input: child.stdout });123 const output = readline.createInterface({ input: child.stdout });
58124
59 let completed = false;125 let completed = false;
126 let account: CodexAccountSnapshot | undefined;
127 let skills: ReadonlyArray<ServerProviderSkill> | undefined;
60128
61 const cleanup = () => {129 const cleanup = () => {
62 output.removeAllListeners();130 output.removeAllListeners();
...@@ -79,15 +147,25 @@ export async function probeCodexAccount(input: {...@@ -79,15 +147,25 @@ export async function probeCodexAccount(input: {
79 reject(147 reject(
80 error instanceof Error148 error instanceof Error
81 ? error149 ? error
82 : new Error(`Codex account probe failed: ${String(error)}.`),150 : new Error(`Codex discovery probe failed: ${String(error)}.`),
83 ),151 ),
84 );152 );
85153
154 const maybeResolve = () => {
155 if (account && skills !== undefined) {
156 const resolvedAccount = account;
157 const resolvedSkills = skills;
158 finish(() => resolve({ account: resolvedAccount, skills: resolvedSkills }));
159 }
160 };
161
86 if (input.signal?.aborted) {162 if (input.signal?.aborted) {
87 fail(new Error("Codex account probe aborted."));163 fail(new Error("Codex discovery probe aborted."));
88 return;164 return;
89 }165 }
90 input.signal?.addEventListener("abort", () => fail(new Error("Codex account probe aborted.")));166 input.signal?.addEventListener("abort", () =>
167 fail(new Error("Codex discovery probe aborted.")),
168 );
91169
92 const writeMessage = (message: unknown) => {170 const writeMessage = (message: unknown) => {
93 if (!child.stdin.writable) {171 if (!child.stdin.writable) {
...@@ -103,7 +181,7 @@ export async function probeCodexAccount(input: {...@@ -103,7 +181,7 @@ export async function probeCodexAccount(input: {
103 try {181 try {
104 parsed = JSON.parse(line);182 parsed = JSON.parse(line);
105 } catch {183 } catch {
106 fail(new Error("Received invalid JSON from codex app-server during account probe."));184 fail(new Error("Received invalid JSON from codex app-server during discovery probe."));
107 return;185 return;
108 }186 }
109187
...@@ -120,18 +198,27 @@ export async function probeCodexAccount(input: {...@@ -120,18 +198,27 @@ export async function probeCodexAccount(input: {
120 }198 }
121199
122 writeMessage({ method: "initialized" });200 writeMessage({ method: "initialized" });
123 writeMessage({ id: 2, method: "account/read", params: {} });201 writeMessage({ id: 2, method: "skills/list", params: { cwds: [input.cwd] } });
202 writeMessage({ id: 3, method: "account/read", params: {} });
124 return;203 return;
125 }204 }
126205
127 if (response.id === 2) {206 if (response.id === 2) {
207 const errorMessage = readErrorMessage(response);
208 skills = errorMessage ? [] : parseCodexSkillsResult(response.result, input.cwd);
209 maybeResolve();
210 return;
211 }
212
213 if (response.id === 3) {
128 const errorMessage = readErrorMessage(response);214 const errorMessage = readErrorMessage(response);
129 if (errorMessage) {215 if (errorMessage) {
130 fail(new Error(`account/read failed: ${errorMessage}`));216 fail(new Error(`account/read failed: ${errorMessage}`));
131 return;217 return;
132 }218 }
133219
134 finish(() => resolve(readCodexAccountSnapshot(response.result)));220 account = readCodexAccountSnapshot(response.result);
221 maybeResolve();
135 }222 }
136 });223 });
137224
apps/server/src/provider/providerSnapshot.ts+6
...@@ -2,6 +2,8 @@ import type {...@@ -2,6 +2,8 @@ import type {
2 ModelCapabilities,2 ModelCapabilities,
3 ServerProvider,3 ServerProvider,
4 ServerProviderAuth,4 ServerProviderAuth,
5 ServerProviderSkill,
6 ServerProviderSlashCommand,
5 ServerProviderModel,7 ServerProviderModel,
6 ServerProviderState,8 ServerProviderState,
7} from "@t3tools/contracts";9} from "@t3tools/contracts";
...@@ -130,6 +132,8 @@ export function buildServerProvider(input: {...@@ -130,6 +132,8 @@ export function buildServerProvider(input: {
130 enabled: boolean;132 enabled: boolean;
131 checkedAt: string;133 checkedAt: string;
132 models: ReadonlyArray<ServerProviderModel>;134 models: ReadonlyArray<ServerProviderModel>;
135 slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;
136 skills?: ReadonlyArray<ServerProviderSkill>;
133 probe: ProviderProbeResult;137 probe: ProviderProbeResult;
134}): ServerProvider {138}): ServerProvider {
135 return {139 return {
...@@ -142,6 +146,8 @@ export function buildServerProvider(input: {...@@ -142,6 +146,8 @@ export function buildServerProvider(input: {
142 checkedAt: input.checkedAt,146 checkedAt: input.checkedAt,
143 ...(input.probe.message ? { message: input.probe.message } : {}),147 ...(input.probe.message ? { message: input.probe.message } : {}),
144 models: input.models,148 models: input.models,
149 slashCommands: [...(input.slashCommands ?? [])],
150 skills: [...(input.skills ?? [])],
145 };151 };
146}152}
147153
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+12
...@@ -129,6 +129,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {...@@ -129,6 +129,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
129 }),129 }),
130 );130 );
131131
132 it.effect("prioritizes exact basename matches ahead of broader path matches", () =>
133 Effect.gen(function* () {
134 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" });
135 yield* writeTextFile(cwd, "src/components/Composer.tsx");
136 yield* writeTextFile(cwd, "docs/composer.tsx-notes.md");
137
138 const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 });
139
140 expect(result.entries[0]?.path).toBe("src/components/Composer.tsx");
141 }),
142 );
143
132 it.effect("tracks truncation without sorting every fuzzy match", () =>144 it.effect("tracks truncation without sorting every fuzzy match", () =>
133 Effect.gen(function* () {145 Effect.gen(function* () {
134 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" });146 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" });
apps/server/src/workspace/Layers/WorkspaceEntries.ts+40-119
...@@ -4,6 +4,12 @@ import type { Dirent } from "node:fs";...@@ -4,6 +4,12 @@ import type { Dirent } from "node:fs";
4import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";4import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";
55
6import { type ProjectEntry } from "@t3tools/contracts";6import { type ProjectEntry } from "@t3tools/contracts";
7import {
8 insertRankedSearchResult,
9 normalizeSearchQuery,
10 scoreQueryMatch,
11 type RankedSearchResult,
12} from "@t3tools/shared/searchRanking";
713
8import { GitCore } from "../../git/Services/GitCore.ts";14import { GitCore } from "../../git/Services/GitCore.ts";
9import {15import {
...@@ -40,10 +46,7 @@ interface SearchableWorkspaceEntry extends ProjectEntry {...@@ -40,10 +46,7 @@ interface SearchableWorkspaceEntry extends ProjectEntry {
40 normalizedName: string;46 normalizedName: string;
41}47}
4248
43interface RankedWorkspaceEntry {49type RankedWorkspaceEntry = RankedSearchResult<SearchableWorkspaceEntry>;
44 entry: SearchableWorkspaceEntry;
45 score: number;
46}
4750
48function toPosixPath(input: string): string {51function toPosixPath(input: string): string {
49 return input.replaceAll("\\", "/");52 return input.replaceAll("\\", "/");
...@@ -74,45 +77,6 @@ function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEnt...@@ -74,45 +77,6 @@ function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEnt
74 };77 };
75}78}
7679
77function normalizeQuery(input: string): string {
78 return input
79 .trim()
80 .replace(/^[@./]+/, "")
81 .toLowerCase();
82}
83
84function scoreSubsequenceMatch(value: string, query: string): number | null {
85 if (!query) return 0;
86
87 let queryIndex = 0;
88 let firstMatchIndex = -1;
89 let previousMatchIndex = -1;
90 let gapPenalty = 0;
91
92 for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) {
93 if (value[valueIndex] !== query[queryIndex]) {
94 continue;
95 }
96
97 if (firstMatchIndex === -1) {
98 firstMatchIndex = valueIndex;
99 }
100 if (previousMatchIndex !== -1) {
101 gapPenalty += valueIndex - previousMatchIndex - 1;
102 }
103
104 previousMatchIndex = valueIndex;
105 queryIndex += 1;
106 if (queryIndex === query.length) {
107 const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length;
108 const lengthPenalty = Math.min(64, value.length - query.length);
109 return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty;
110 }
111 }
112
113 return null;
114}
115
116function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null {80function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null {
117 if (!query) {81 if (!query) {
118 return entry.kind === "directory" ? 0 : 1;82 return entry.kind === "directory" ? 0 : 1;
...@@ -120,81 +84,32 @@ function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | nu...@@ -120,81 +84,32 @@ function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | nu
12084
121 const { normalizedPath, normalizedName } = entry;85 const { normalizedPath, normalizedName } = entry;
12286
123 if (normalizedName === query) return 0;87 const scores = [
124 if (normalizedPath === query) return 1;88 scoreQueryMatch({
125 if (normalizedName.startsWith(query)) return 2;89 value: normalizedName,
126 if (normalizedPath.startsWith(query)) return 3;90 query,
127 if (normalizedPath.includes(`/${query}`)) return 4;91 exactBase: 0,
128 if (normalizedName.includes(query)) return 5;92 prefixBase: 2,
129 if (normalizedPath.includes(query)) return 6;93 includesBase: 5,
13094 fuzzyBase: 100,
131 const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query);95 }),
132 if (nameFuzzyScore !== null) {96 scoreQueryMatch({
133 return 100 + nameFuzzyScore;97 value: normalizedPath,
134 }98 query,
13599 exactBase: 1,
136 const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query);100 prefixBase: 3,
137 if (pathFuzzyScore !== null) {101 boundaryBase: 4,
138 return 200 + pathFuzzyScore;102 includesBase: 6,
103 fuzzyBase: 200,
104 boundaryMarkers: ["/"],
105 }),
106 ].filter((score): score is number => score !== null);
107
108 if (scores.length === 0) {
109 return null;
139 }110 }
140111
141 return null;112 return Math.min(...scores);
142}
143
144function compareRankedWorkspaceEntries(
145 left: RankedWorkspaceEntry,
146 right: RankedWorkspaceEntry,
147): number {
148 const scoreDelta = left.score - right.score;
149 if (scoreDelta !== 0) return scoreDelta;
150 return left.entry.path.localeCompare(right.entry.path);
151}
152
153function findInsertionIndex(
154 rankedEntries: RankedWorkspaceEntry[],
155 candidate: RankedWorkspaceEntry,
156): number {
157 let low = 0;
158 let high = rankedEntries.length;
159
160 while (low < high) {
161 const middle = low + Math.floor((high - low) / 2);
162 const current = rankedEntries[middle];
163 if (!current) {
164 break;
165 }
166
167 if (compareRankedWorkspaceEntries(candidate, current) < 0) {
168 high = middle;
169 } else {
170 low = middle + 1;
171 }
172 }
173
174 return low;
175}
176
177function insertRankedEntry(
178 rankedEntries: RankedWorkspaceEntry[],
179 candidate: RankedWorkspaceEntry,
180 limit: number,
181): void {
182 if (limit <= 0) {
183 return;
184 }
185
186 const insertionIndex = findInsertionIndex(rankedEntries, candidate);
187 if (rankedEntries.length < limit) {
188 rankedEntries.splice(insertionIndex, 0, candidate);
189 return;
190 }
191
192 if (insertionIndex >= limit) {
193 return;
194 }
195
196 rankedEntries.splice(insertionIndex, 0, candidate);
197 rankedEntries.pop();
198}113}
199114
200function isPathInIgnoredDirectory(relativePath: string): boolean {115function isPathInIgnoredDirectory(relativePath: string): boolean {
...@@ -469,7 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () {...@@ -469,7 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
469 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);384 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
470 return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe(385 return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe(
471 Effect.map((index) => {386 Effect.map((index) => {
472 const normalizedQuery = normalizeQuery(input.query);387 const normalizedQuery = normalizeSearchQuery(input.query, {
388 trimLeadingPattern: /^[@./]+/,
389 });
473 const limit = Math.max(0, Math.floor(input.limit));390 const limit = Math.max(0, Math.floor(input.limit));
474 const rankedEntries: RankedWorkspaceEntry[] = [];391 const rankedEntries: RankedWorkspaceEntry[] = [];
475 let matchedEntryCount = 0;392 let matchedEntryCount = 0;
...@@ -481,11 +398,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () {...@@ -481,11 +398,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
481 }398 }
482399
483 matchedEntryCount += 1;400 matchedEntryCount += 1;
484 insertRankedEntry(rankedEntries, { entry, score }, limit);401 insertRankedSearchResult(
402 rankedEntries,
403 { item: entry, score, tieBreaker: entry.path },
404 limit,
405 );
485 }406 }
486407
487 return {408 return {
488 entries: rankedEntries.map((candidate) => candidate.entry),409 entries: rankedEntries.map((candidate) => candidate.item),
489 truncated: index.truncated || matchedEntryCount > limit,410 truncated: index.truncated || matchedEntryCount > limit,
490 };411 };
491 }),412 }),
apps/web/src/components/ChatView.browser.tsx+53
...@@ -169,6 +169,8 @@ function createBaseServerConfig(): ServerConfig {...@@ -169,6 +169,8 @@ function createBaseServerConfig(): ServerConfig {
169 auth: { status: "authenticated" },169 auth: { status: "authenticated" },
170 checkedAt: NOW_ISO,170 checkedAt: NOW_ISO,
171 models: [],171 models: [],
172 slashCommands: [],
173 skills: [],
172 },174 },
173 ],175 ],
174 availableEditors: [],176 availableEditors: [],
...@@ -3885,4 +3887,55 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3885,4 +3887,55 @@ describe("ChatView timeline estimator parity (full app)", () => {
3885 await mounted.cleanup();3887 await mounted.cleanup();
3886 }3888 }
3887 });3889 });
3890
3891 it("shows a tooltip with the skill description when hovering a skill pill", async () => {
3892 const mounted = await mountChatView({
3893 viewport: DEFAULT_VIEWPORT,
3894 snapshot: createSnapshotForTargetUser({
3895 targetMessageId: "msg-user-skill-tooltip-target" as MessageId,
3896 targetText: "skill tooltip thread",
3897 }),
3898 configureFixture: (nextFixture) => {
3899 const provider = nextFixture.serverConfig.providers[0];
3900 if (!provider) {
3901 throw new Error("Expected default provider in test fixture.");
3902 }
3903 (
3904 provider as {
3905 skills: ServerConfig["providers"][number]["skills"];
3906 }
3907 ).skills = [
3908 {
3909 name: "agent-browser",
3910 displayName: "Agent Browser",
3911 description: "Open pages, click around, and inspect web apps.",
3912 path: "/Users/test/.agents/skills/agent-browser/SKILL.md",
3913 enabled: true,
3914 },
3915 ];
3916 },
3917 });
3918
3919 try {
3920 useComposerDraftStore.getState().setPrompt(THREAD_REF, "use the $agent-browser ");
3921 await waitForComposerText("use the $agent-browser ");
3922
3923 await waitForElement(
3924 () => document.querySelector<HTMLElement>('[data-composer-skill-chip="true"]'),
3925 "Unable to find rendered composer skill chip.",
3926 );
3927 await page.getByText("Agent Browser").hover();
3928
3929 await vi.waitFor(
3930 () => {
3931 const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]');
3932 expect(tooltip).not.toBeNull();
3933 expect(tooltip?.textContent).toContain("Open pages, click around, and inspect web apps.");
3934 },
3935 { timeout: 8_000, interval: 16 },
3936 );
3937 } finally {
3938 await mounted.cleanup();
3939 }
3940 });
3888});3941});
apps/web/src/components/ComposerPromptEditor.tsx+237-14
...@@ -5,6 +5,7 @@ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";...@@ -5,6 +5,7 @@ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
5import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";5import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
6import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";6import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
7import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";7import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
8import { type ServerProviderSkill } from "@t3tools/contracts";
8import {9import {
9 $applyNodeReplacement,10 $applyNodeReplacement,
10 $createRangeSelection,11 $createRangeSelection,
...@@ -73,8 +74,11 @@ import {...@@ -73,8 +74,11 @@ import {
73 COMPOSER_INLINE_CHIP_CLASS_NAME,74 COMPOSER_INLINE_CHIP_CLASS_NAME,
74 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,75 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
75 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,76 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
77 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
76} from "./composerInlineChip";78} from "./composerInlineChip";
77import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";79import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
80import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
81import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
7882
79const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;83const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
80const SURROUND_SYMBOLS: [string, string][] = [84const SURROUND_SYMBOLS: [string, string][] = [
...@@ -102,6 +106,17 @@ type SerializedComposerMentionNode = Spread<...@@ -102,6 +106,17 @@ type SerializedComposerMentionNode = Spread<
102 SerializedTextNode106 SerializedTextNode
103>;107>;
104108
109type SerializedComposerSkillNode = Spread<
110 {
111 skillName: string;
112 skillLabel?: string;
113 skillDescription?: string;
114 type: "composer-skill";
115 version: 1;
116 },
117 SerializedLexicalNode
118>;
119
105type SerializedComposerTerminalContextNode = Spread<120type SerializedComposerTerminalContextNode = Spread<
106 {121 {
107 context: TerminalContextDraft;122 context: TerminalContextDraft;
...@@ -189,6 +204,155 @@ function $createComposerMentionNode(path: string): ComposerMentionNode {...@@ -189,6 +204,155 @@ function $createComposerMentionNode(path: string): ComposerMentionNode {
189 return $applyNodeReplacement(new ComposerMentionNode(path));204 return $applyNodeReplacement(new ComposerMentionNode(path));
190}205}
191206
207const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
208
209function resolveSkillDescription(
210 skill: Pick<ServerProviderSkill, "shortDescription" | "description">,
211): string | null {
212 const shortDescription = skill.shortDescription?.trim();
213 if (shortDescription) {
214 return shortDescription;
215 }
216 const description = skill.description?.trim();
217 return description || null;
218}
219
220type ComposerSkillMetadata = {
221 label: string;
222 description: string | null;
223};
224
225function skillMetadataByName(
226 skills: ReadonlyArray<ServerProviderSkill>,
227): ReadonlyMap<string, ComposerSkillMetadata> {
228 return new Map(
229 skills.map((skill) => [
230 skill.name,
231 {
232 label: formatProviderSkillDisplayName(skill),
233 description: resolveSkillDescription(skill),
234 },
235 ]),
236 );
237}
238
239function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: string | null }) {
240 const chip = (
241 <span
242 className={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}
243 contentEditable={false}
244 spellCheck={false}
245 data-composer-skill-chip="true"
246 >
247 <span
248 aria-hidden="true"
249 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
250 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
251 />
252 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{props.skillLabel}</span>
253 </span>
254 );
255
256 if (!props.skillDescription) {
257 return chip;
258 }
259
260 return (
261 <Tooltip>
262 <TooltipTrigger render={chip} />
263 <TooltipPopup side="top" className="max-w-[30rem] whitespace-normal leading-tight">
264 {props.skillDescription}
265 </TooltipPopup>
266 </Tooltip>
267 );
268}
269
270class ComposerSkillNode extends DecoratorNode<ReactElement> {
271 __skillName: string;
272 __skillLabel: string;
273 __skillDescription: string | null;
274
275 static override getType(): string {
276 return "composer-skill";
277 }
278
279 static override clone(node: ComposerSkillNode): ComposerSkillNode {
280 return new ComposerSkillNode(
281 node.__skillName,
282 node.__skillLabel,
283 node.__skillDescription,
284 node.__key,
285 );
286 }
287
288 static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode {
289 return $createComposerSkillNode(
290 serializedNode.skillName,
291 serializedNode.skillLabel ?? serializedNode.skillName,
292 serializedNode.skillDescription ?? null,
293 ).updateFromJSON(serializedNode);
294 }
295
296 constructor(
297 skillName: string,
298 skillLabel: string,
299 skillDescription: string | null,
300 key?: NodeKey,
301 ) {
302 super(key);
303 const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName;
304 this.__skillName = normalizedSkillName;
305 this.__skillLabel = skillLabel;
306 this.__skillDescription = skillDescription;
307 }
308
309 override exportJSON(): SerializedComposerSkillNode {
310 return {
311 ...super.exportJSON(),
312 skillName: this.__skillName,
313 skillLabel: this.__skillLabel,
314 ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}),
315 type: "composer-skill",
316 version: 1,
317 };
318 }
319
320 override createDOM(): HTMLElement {
321 const dom = document.createElement("span");
322 dom.className = "inline-flex align-middle leading-none";
323 return dom;
324 }
325
326 override updateDOM(): false {
327 return false;
328 }
329
330 override getTextContent(): string {
331 return `$${this.__skillName}`;
332 }
333
334 override isInline(): true {
335 return true;
336 }
337
338 override decorate(): ReactElement {
339 return (
340 <ComposerSkillDecorator
341 skillLabel={this.__skillLabel}
342 skillDescription={this.__skillDescription}
343 />
344 );
345 }
346}
347
348function $createComposerSkillNode(
349 skillName: string,
350 skillLabel: string,
351 skillDescription: string | null,
352): ComposerSkillNode {
353 return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription));
354}
355
192function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) {356function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) {
193 return <ComposerPendingTerminalContextChip context={props.context} />;357 return <ComposerPendingTerminalContextChip context={props.context} />;
194}358}
...@@ -253,11 +417,16 @@ function $createComposerTerminalContextNode(...@@ -253,11 +417,16 @@ function $createComposerTerminalContextNode(
253 return $applyNodeReplacement(new ComposerTerminalContextNode(context));417 return $applyNodeReplacement(new ComposerTerminalContextNode(context));
254}418}
255419
256type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode;420type ComposerInlineTokenNode =
421 | ComposerMentionNode
422 | ComposerSkillNode
423 | ComposerTerminalContextNode;
257424
258function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode {425function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode {
259 return (426 return (
260 candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode427 candidate instanceof ComposerMentionNode ||
428 candidate instanceof ComposerSkillNode ||
429 candidate instanceof ComposerTerminalContextNode
261 );430 );
262}431}
263432
...@@ -302,6 +471,22 @@ function terminalContextSignature(contexts: ReadonlyArray<TerminalContextDraft>)...@@ -302,6 +471,22 @@ function terminalContextSignature(contexts: ReadonlyArray<TerminalContextDraft>)
302 .join("\u001e");471 .join("\u001e");
303}472}
304473
474function skillSignature(skills: ReadonlyArray<ServerProviderSkill>): string {
475 return skills
476 .map((skill) =>
477 [
478 skill.name,
479 skill.displayName ?? "",
480 skill.shortDescription ?? "",
481 skill.description ?? "",
482 skill.path,
483 skill.scope ?? "",
484 skill.enabled ? "1" : "0",
485 ].join("\u001f"),
486 )
487 .join("\u001e");
488}
489
305function clampExpandedCursor(value: string, cursor: number): number {490function clampExpandedCursor(value: string, cursor: number): number {
306 if (!Number.isFinite(cursor)) return value.length;491 if (!Number.isFinite(cursor)) return value.length;
307 return Math.max(0, Math.min(value.length, Math.floor(cursor)));492 return Math.max(0, Math.min(value.length, Math.floor(cursor)));
...@@ -415,7 +600,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb...@@ -415,7 +600,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
415 }600 }
416 return offset + Math.min(pointOffset, node.getTextContentSize());601 return offset + Math.min(pointOffset, node.getTextContentSize());
417 }602 }
418 if (node instanceof ComposerTerminalContextNode) {603 if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) {
419 return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);604 return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
420 }605 }
421606
...@@ -462,7 +647,7 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe...@@ -462,7 +647,7 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe
462 }647 }
463 return offset + Math.min(pointOffset, node.getTextContentSize());648 return offset + Math.min(pointOffset, node.getTextContentSize());
464 }649 }
465 if (node instanceof ComposerTerminalContextNode) {650 if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) {
466 return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);651 return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
467 }652 }
468653
...@@ -488,7 +673,7 @@ function findSelectionPointAtOffset(...@@ -488,7 +673,7 @@ function findSelectionPointAtOffset(
488 node: LexicalNode,673 node: LexicalNode,
489 remainingRef: { value: number },674 remainingRef: { value: number },
490): { key: string; offset: number; type: "text" | "element" } | null {675): { key: string; offset: number; type: "text" | "element" } | null {
491 if (node instanceof ComposerMentionNode) {676 if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) {
492 return findSelectionPointForInlineToken(node, remainingRef);677 return findSelectionPointForInlineToken(node, remainingRef);
493 }678 }
494 if (node instanceof ComposerTerminalContextNode) {679 if (node instanceof ComposerTerminalContextNode) {
...@@ -657,6 +842,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {...@@ -657,6 +842,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
657function $setComposerEditorPrompt(842function $setComposerEditorPrompt(
658 prompt: string,843 prompt: string,
659 terminalContexts: ReadonlyArray<TerminalContextDraft>,844 terminalContexts: ReadonlyArray<TerminalContextDraft>,
845 skillMetadata: ReadonlyMap<string, ComposerSkillMetadata>,
660): void {846): void {
661 const root = $getRoot();847 const root = $getRoot();
662 root.clear();848 root.clear();
...@@ -669,6 +855,17 @@ function $setComposerEditorPrompt(...@@ -669,6 +855,17 @@ function $setComposerEditorPrompt(
669 paragraph.append($createComposerMentionNode(segment.path));855 paragraph.append($createComposerMentionNode(segment.path));
670 continue;856 continue;
671 }857 }
858 if (segment.type === "skill") {
859 const metadata = skillMetadata.get(segment.name);
860 paragraph.append(
861 $createComposerSkillNode(
862 segment.name,
863 metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }),
864 metadata?.description ?? null,
865 ),
866 );
867 continue;
868 }
672 if (segment.type === "terminal-context") {869 if (segment.type === "terminal-context") {
673 if (segment.context) {870 if (segment.context) {
674 paragraph.append($createComposerTerminalContextNode(segment.context));871 paragraph.append($createComposerTerminalContextNode(segment.context));
...@@ -705,6 +902,7 @@ interface ComposerPromptEditorProps {...@@ -705,6 +902,7 @@ interface ComposerPromptEditorProps {
705 value: string;902 value: string;
706 cursor: number;903 cursor: number;
707 terminalContexts: ReadonlyArray<TerminalContextDraft>;904 terminalContexts: ReadonlyArray<TerminalContextDraft>;
905 skills: ReadonlyArray<ServerProviderSkill>;
708 disabled: boolean;906 disabled: boolean;
709 placeholder: string;907 placeholder: string;
710 className?: string;908 className?: string;
...@@ -946,9 +1144,11 @@ function ComposerInlineTokenBackspacePlugin() {...@@ -946,9 +1144,11 @@ function ComposerInlineTokenBackspacePlugin() {
9461144
947function ComposerSurroundSelectionPlugin(props: {1145function ComposerSurroundSelectionPlugin(props: {
948 terminalContexts: ReadonlyArray<TerminalContextDraft>;1146 terminalContexts: ReadonlyArray<TerminalContextDraft>;
1147 skills: ReadonlyArray<ServerProviderSkill>;
949}) {1148}) {
950 const [editor] = useLexicalComposerContext();1149 const [editor] = useLexicalComposerContext();
951 const terminalContextsRef = useRef(props.terminalContexts);1150 const terminalContextsRef = useRef(props.terminalContexts);
1151 const skillMetadataRef = useRef(skillMetadataByName(props.skills));
952 const pendingSurroundSelectionRef = useRef<{1152 const pendingSurroundSelectionRef = useRef<{
953 value: string;1153 value: string;
954 expandedStart: number;1154 expandedStart: number;
...@@ -964,6 +1164,10 @@ function ComposerSurroundSelectionPlugin(props: {...@@ -964,6 +1164,10 @@ function ComposerSurroundSelectionPlugin(props: {
964 terminalContextsRef.current = props.terminalContexts;1164 terminalContextsRef.current = props.terminalContexts;
965 }, [props.terminalContexts]);1165 }, [props.terminalContexts]);
9661166
1167 useEffect(() => {
1168 skillMetadataRef.current = skillMetadataByName(props.skills);
1169 }, [props.skills]);
1170
967 const applySurroundInsertion = useCallback(1171 const applySurroundInsertion = useCallback(
968 (inputData: string): boolean => {1172 (inputData: string): boolean => {
969 const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);1173 const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);
...@@ -1009,7 +1213,7 @@ function ComposerSurroundSelectionPlugin(props: {...@@ -1009,7 +1213,7 @@ function ComposerSurroundSelectionPlugin(props: {
1009 selectionSnapshot.expandedEnd,1213 selectionSnapshot.expandedEnd,
1010 );1214 );
1011 const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;1215 const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;
1012 $setComposerEditorPrompt(nextValue, terminalContextsRef.current);1216 $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current);
1013 const selectionStart = collapseExpandedComposerCursor(1217 const selectionStart = collapseExpandedComposerCursor(
1014 nextValue,1218 nextValue,
1015 selectionSnapshot.expandedStart,1219 selectionSnapshot.expandedStart,
...@@ -1211,6 +1415,7 @@ function ComposerPromptEditorInner({...@@ -1211,6 +1415,7 @@ function ComposerPromptEditorInner({
1211 value,1415 value,
1212 cursor,1416 cursor,
1213 terminalContexts,1417 terminalContexts,
1418 skills,
1214 disabled,1419 disabled,
1215 placeholder,1420 placeholder,
1216 className,1421 className,
...@@ -1225,6 +1430,9 @@ function ComposerPromptEditorInner({...@@ -1225,6 +1430,9 @@ function ComposerPromptEditorInner({
1225 const initialCursor = clampCollapsedComposerCursor(value, cursor);1430 const initialCursor = clampCollapsedComposerCursor(value, cursor);
1226 const terminalContextsSignature = terminalContextSignature(terminalContexts);1431 const terminalContextsSignature = terminalContextSignature(terminalContexts);
1227 const terminalContextsSignatureRef = useRef(terminalContextsSignature);1432 const terminalContextsSignatureRef = useRef(terminalContextsSignature);
1433 const skillsSignature = skillSignature(skills);
1434 const skillsSignatureRef = useRef(skillsSignature);
1435 const skillMetadataRef = useRef(skillMetadataByName(skills));
1228 const snapshotRef = useRef({1436 const snapshotRef = useRef({
1229 value,1437 value,
1230 cursor: initialCursor,1438 cursor: initialCursor,
...@@ -1241,6 +1449,10 @@ function ComposerPromptEditorInner({...@@ -1241,6 +1449,10 @@ function ComposerPromptEditorInner({
1241 onChangeRef.current = onChange;1449 onChangeRef.current = onChange;
1242 }, [onChange]);1450 }, [onChange]);
12431451
1452 useLayoutEffect(() => {
1453 skillMetadataRef.current = skillMetadataByName(skills);
1454 }, [skills]);
1455
1244 useEffect(() => {1456 useEffect(() => {
1245 editor.setEditable(!disabled);1457 editor.setEditable(!disabled);
1246 }, [disabled, editor]);1458 }, [disabled, editor]);
...@@ -1249,10 +1461,12 @@ function ComposerPromptEditorInner({...@@ -1249,10 +1461,12 @@ function ComposerPromptEditorInner({
1249 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);1461 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
1250 const previousSnapshot = snapshotRef.current;1462 const previousSnapshot = snapshotRef.current;
1251 const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature;1463 const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature;
1464 const skillsChanged = skillsSignatureRef.current !== skillsSignature;
1252 if (1465 if (
1253 previousSnapshot.value === value &&1466 previousSnapshot.value === value &&
1254 previousSnapshot.cursor === normalizedCursor &&1467 previousSnapshot.cursor === normalizedCursor &&
1255 !contextsChanged1468 !contextsChanged &&
1469 !skillsChanged
1256 ) {1470 ) {
1257 return;1471 return;
1258 }1472 }
...@@ -1264,18 +1478,20 @@ function ComposerPromptEditorInner({...@@ -1264,18 +1478,20 @@ function ComposerPromptEditorInner({
1264 terminalContextIds: terminalContexts.map((context) => context.id),1478 terminalContextIds: terminalContexts.map((context) => context.id),
1265 };1479 };
1266 terminalContextsSignatureRef.current = terminalContextsSignature;1480 terminalContextsSignatureRef.current = terminalContextsSignature;
1481 skillsSignatureRef.current = skillsSignature;
12671482
1268 const rootElement = editor.getRootElement();1483 const rootElement = editor.getRootElement();
1269 const isFocused = Boolean(rootElement && document.activeElement === rootElement);1484 const isFocused = Boolean(rootElement && document.activeElement === rootElement);
1270 if (previousSnapshot.value === value && !contextsChanged && !isFocused) {1485 if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) {
1271 return;1486 return;
1272 }1487 }
12731488
1274 isApplyingControlledUpdateRef.current = true;1489 isApplyingControlledUpdateRef.current = true;
1275 editor.update(() => {1490 editor.update(() => {
1276 const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged;1491 const shouldRewriteEditorState =
1492 previousSnapshot.value !== value || contextsChanged || skillsChanged;
1277 if (shouldRewriteEditorState) {1493 if (shouldRewriteEditorState) {
1278 $setComposerEditorPrompt(value, terminalContexts);1494 $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current);
1279 }1495 }
1280 if (shouldRewriteEditorState || isFocused) {1496 if (shouldRewriteEditorState || isFocused) {
1281 $setSelectionAtComposerOffset(normalizedCursor);1497 $setSelectionAtComposerOffset(normalizedCursor);
...@@ -1284,7 +1500,7 @@ function ComposerPromptEditorInner({...@@ -1284,7 +1500,7 @@ function ComposerPromptEditorInner({
1284 queueMicrotask(() => {1500 queueMicrotask(() => {
1285 isApplyingControlledUpdateRef.current = false;1501 isApplyingControlledUpdateRef.current = false;
1286 });1502 });
1287 }, [cursor, editor, terminalContexts, terminalContextsSignature, value]);1503 }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]);
12881504
1289 const focusAt = useCallback(1505 const focusAt = useCallback(
1290 (nextCursor: number) => {1506 (nextCursor: number) => {
...@@ -1442,7 +1658,7 @@ function ComposerPromptEditorInner({...@@ -1442,7 +1658,7 @@ function ComposerPromptEditorInner({
1442 />1658 />
1443 <OnChangePlugin onChange={handleEditorChange} />1659 <OnChangePlugin onChange={handleEditorChange} />
1444 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />1660 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
1445 <ComposerSurroundSelectionPlugin terminalContexts={terminalContexts} />1661 <ComposerSurroundSelectionPlugin terminalContexts={terminalContexts} skills={skills} />
1446 <ComposerInlineTokenArrowPlugin />1662 <ComposerInlineTokenArrowPlugin />
1447 <ComposerInlineTokenSelectionNormalizePlugin />1663 <ComposerInlineTokenSelectionNormalizePlugin />
1448 <ComposerInlineTokenBackspacePlugin />1664 <ComposerInlineTokenBackspacePlugin />
...@@ -1460,6 +1676,7 @@ export const ComposerPromptEditor = forwardRef<...@@ -1460,6 +1676,7 @@ export const ComposerPromptEditor = forwardRef<
1460 value,1676 value,
1461 cursor,1677 cursor,
1462 terminalContexts,1678 terminalContexts,
1679 skills,
1463 disabled,1680 disabled,
1464 placeholder,1681 placeholder,
1465 className,1682 className,
...@@ -1472,13 +1689,18 @@ export const ComposerPromptEditor = forwardRef<...@@ -1472,13 +1689,18 @@ export const ComposerPromptEditor = forwardRef<
1472) {1689) {
1473 const initialValueRef = useRef(value);1690 const initialValueRef = useRef(value);
1474 const initialTerminalContextsRef = useRef(terminalContexts);1691 const initialTerminalContextsRef = useRef(terminalContexts);
1692 const initialSkillMetadataRef = useRef(skillMetadataByName(skills));
1475 const initialConfig = useMemo<InitialConfigType>(1693 const initialConfig = useMemo<InitialConfigType>(
1476 () => ({1694 () => ({
1477 namespace: "t3tools-composer-editor",1695 namespace: "t3tools-composer-editor",
1478 editable: true,1696 editable: true,
1479 nodes: [ComposerMentionNode, ComposerTerminalContextNode],1697 nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode],
1480 editorState: () => {1698 editorState: () => {
1481 $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current);1699 $setComposerEditorPrompt(
1700 initialValueRef.current,
1701 initialTerminalContextsRef.current,
1702 initialSkillMetadataRef.current,
1703 );
1482 },1704 },
1483 onError: (error) => {1705 onError: (error) => {
1484 throw error;1706 throw error;
...@@ -1493,6 +1715,7 @@ export const ComposerPromptEditor = forwardRef<...@@ -1493,6 +1715,7 @@ export const ComposerPromptEditor = forwardRef<
1493 value={value}1715 value={value}
1494 cursor={cursor}1716 cursor={cursor}
1495 terminalContexts={terminalContexts}1717 terminalContexts={terminalContexts}
1718 skills={skills}
1496 disabled={disabled}1719 disabled={disabled}
1497 placeholder={placeholder}1720 placeholder={placeholder}
1498 onRemoveTerminalContext={onRemoveTerminalContext}1721 onRemoveTerminalContext={onRemoveTerminalContext}
apps/web/src/components/KeybindingsToast.browser.tsx+2
...@@ -80,6 +80,8 @@ function createBaseServerConfig(): ServerConfig {...@@ -80,6 +80,8 @@ function createBaseServerConfig(): ServerConfig {
80 auth: { status: "authenticated" },80 auth: { status: "authenticated" },
81 checkedAt: NOW_ISO,81 checkedAt: NOW_ISO,
82 models: [],82 models: [],
83 slashCommands: [],
84 skills: [],
83 },85 },
84 ],86 ],
85 availableEditors: [],87 availableEditors: [],
apps/web/src/components/chat/ChatComposer.tsx+141-22
...@@ -67,6 +67,8 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions";...@@ -67,6 +67,8 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
67import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";67import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
68import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";68import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
69import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";69import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
70import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
71import { searchSlashCommandItems } from "./composerSlashCommandSearch";
70import {72import {
71 getComposerProviderState,73 getComposerProviderState,
72 renderProviderTraitsMenuContent,74 renderProviderTraitsMenuContent,
...@@ -98,6 +100,8 @@ import type { SessionPhase, Thread } from "../../types";...@@ -98,6 +100,8 @@ import type { SessionPhase, Thread } from "../../types";
98import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";100import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
99import type { PendingApproval, PendingUserInput } from "../../session-logic";101import type { PendingApproval, PendingUserInput } from "../../session-logic";
100import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";102import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
103import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
104import { searchProviderSkills } from "../../providerSkillSearch";
101105
102const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;106const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
103107
...@@ -558,6 +562,10 @@ export const ChatComposer = memo(...@@ -558,6 +562,10 @@ export const ChatComposer = memo(
558 });562 });
559563
560 const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider);564 const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider);
565 const selectedProviderStatus = useMemo(
566 () => providerStatuses.find((provider) => provider.provider === selectedProvider),
567 [providerStatuses, selectedProvider],
568 );
561569
562 const composerProviderState = useMemo(570 const composerProviderState = useMemo(
563 () =>571 () =>
...@@ -634,6 +642,9 @@ export const ChatComposer = memo(...@@ -634,6 +642,9 @@ export const ChatComposer = memo(
634 detectComposerTrigger(prompt, prompt.length),642 detectComposerTrigger(prompt, prompt.length),
635 );643 );
636 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);644 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
645 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
646 null,
647 );
637 const [isDragOverComposer, setIsDragOverComposer] = useState(false);648 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
638 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);649 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
639 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);650 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
...@@ -699,7 +710,7 @@ export const ChatComposer = memo(...@@ -699,7 +710,7 @@ export const ChatComposer = memo(
699 }));710 }));
700 }711 }
701 if (composerTrigger.kind === "slash-command") {712 if (composerTrigger.kind === "slash-command") {
702 const slashCommandItems = [713 const builtInSlashCommandItems = [
703 {714 {
704 id: "slash:model",715 id: "slash:model",
705 type: "slash-command",716 type: "slash-command",
...@@ -722,13 +733,38 @@ export const ChatComposer = memo(...@@ -722,13 +733,38 @@ export const ChatComposer = memo(
722 description: "Switch this thread back to normal build mode",733 description: "Switch this thread back to normal build mode",
723 },734 },
724 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;735 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
736 const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map(
737 (command) => ({
738 id: `provider-slash-command:${selectedProvider}:${command.name}`,
739 type: "provider-slash-command" as const,
740 provider: selectedProvider,
741 command,
742 label: `/${command.name}`,
743 description: command.description ?? command.input?.hint ?? "Run provider command",
744 }),
745 );
725 const query = composerTrigger.query.trim().toLowerCase();746 const query = composerTrigger.query.trim().toLowerCase();
747 const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems];
726 if (!query) {748 if (!query) {
727 return [...slashCommandItems];749 return slashCommandItems;
728 }750 }
729 return slashCommandItems.filter(751 return searchSlashCommandItems(slashCommandItems, query);
730 (item) => item.command.includes(query) || item.label.slice(1).includes(query),752 }
731 );753 if (composerTrigger.kind === "skill") {
754 return searchProviderSkills(
755 selectedProviderStatus?.skills ?? [],
756 composerTrigger.query,
757 ).map((skill) => ({
758 id: `skill:${selectedProvider}:${skill.name}`,
759 type: "skill" as const,
760 provider: selectedProvider,
761 skill,
762 label: formatProviderSkillDisplayName(skill),
763 description:
764 skill.shortDescription ??
765 skill.description ??
766 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
767 }));
732 }768 }
733 return searchableModelOptions769 return searchableModelOptions
734 .filter(({ searchSlug, searchName, searchProvider }) => {770 .filter(({ searchSlug, searchName, searchProvider }) => {
...@@ -748,16 +784,32 @@ export const ChatComposer = memo(...@@ -748,16 +784,32 @@ export const ChatComposer = memo(
748 label: name,784 label: name,
749 description: `${providerLabel} · ${slug}`,785 description: `${providerLabel} · ${slug}`,
750 }));786 }));
751 }, [composerTrigger, searchableModelOptions, workspaceEntries]);787 }, [
788 composerTrigger,
789 searchableModelOptions,
790 selectedProvider,
791 selectedProviderStatus,
792 workspaceEntries,
793 ]);
752794
753 const composerMenuOpen = Boolean(composerTrigger);795 const composerMenuOpen = Boolean(composerTrigger);
754 const activeComposerMenuItem = useMemo(796 const composerMenuSearchKey = composerTrigger
755 () =>797 ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
756 composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??798 : null;
757 composerMenuItems[0] ??799 const activeComposerMenuItem = useMemo(() => {
758 null,800 const activeItemId = resolveComposerMenuActiveItemId({
759 [composerHighlightedItemId, composerMenuItems],801 items: composerMenuItems,
760 );802 highlightedItemId: composerHighlightedItemId,
803 currentSearchKey: composerMenuSearchKey,
804 highlightedSearchKey: composerHighlightedSearchKey,
805 });
806 return composerMenuItems.find((item) => item.id === activeItemId) ?? null;
807 }, [
808 composerHighlightedItemId,
809 composerHighlightedSearchKey,
810 composerMenuItems,
811 composerMenuSearchKey,
812 ]);
761813
762 composerMenuOpenRef.current = composerMenuOpen;814 composerMenuOpenRef.current = composerMenuOpen;
763 composerMenuItemsRef.current = composerMenuItems;815 composerMenuItemsRef.current = composerMenuItems;
...@@ -805,14 +857,21 @@ export const ChatComposer = memo(...@@ -805,14 +857,21 @@ export const ChatComposer = memo(
805 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||857 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
806 workspaceEntriesQuery.isLoading ||858 workspaceEntriesQuery.isLoading ||
807 workspaceEntriesQuery.isFetching);859 workspaceEntriesQuery.isFetching);
860 const composerMenuEmptyState = useMemo(() => {
861 if (composerTriggerKind === "skill") {
862 return "No skills found. Try / to browse provider commands.";
863 }
864 return composerTriggerKind === "path"
865 ? "No matching files or folders."
866 : "No matching command.";
867 }, [composerTriggerKind]);
808868
809 // ------------------------------------------------------------------869 // ------------------------------------------------------------------
810 // Provider traits UI870 // Provider traits UI
811 // ------------------------------------------------------------------871 // ------------------------------------------------------------------
812 const setPromptFromTraits = useCallback(872 const setPromptFromTraits = useCallback(
813 (nextPrompt: string) => {873 (nextPrompt: string) => {
814 const currentPrompt = promptRef.current;874 if (nextPrompt === promptRef.current) {
815 if (nextPrompt === currentPrompt) {
816 scheduleComposerFocus();875 scheduleComposerFocus();
817 return;876 return;
818 }877 }
...@@ -936,14 +995,28 @@ export const ChatComposer = memo(...@@ -936,14 +995,28 @@ export const ChatComposer = memo(
936 useEffect(() => {995 useEffect(() => {
937 if (!composerMenuOpen) {996 if (!composerMenuOpen) {
938 setComposerHighlightedItemId(null);997 setComposerHighlightedItemId(null);
998 setComposerHighlightedSearchKey(null);
939 return;999 return;
940 }1000 }
1001 const nextActiveItemId = resolveComposerMenuActiveItemId({
1002 items: composerMenuItems,
1003 highlightedItemId: composerHighlightedItemId,
1004 currentSearchKey: composerMenuSearchKey,
1005 highlightedSearchKey: composerHighlightedSearchKey,
1006 });
941 setComposerHighlightedItemId((existing) =>1007 setComposerHighlightedItemId((existing) =>
942 existing && composerMenuItems.some((item) => item.id === existing)1008 existing === nextActiveItemId ? existing : nextActiveItemId,
943 ? existing
944 : (composerMenuItems[0]?.id ?? null),
945 );1009 );
946 }, [composerMenuItems, composerMenuOpen]);1010 setComposerHighlightedSearchKey((existing) =>
1011 existing === composerMenuSearchKey ? existing : composerMenuSearchKey,
1012 );
1013 }, [
1014 composerHighlightedItemId,
1015 composerHighlightedSearchKey,
1016 composerMenuItems,
1017 composerMenuOpen,
1018 composerMenuSearchKey,
1019 ]);
9471020
948 const lastSyncedPendingInputRef = useRef<{1021 const lastSyncedPendingInputRef = useRef<{
949 requestId: string | null;1022 requestId: string | null;
...@@ -1314,6 +1387,42 @@ export const ChatComposer = memo(...@@ -1314,6 +1387,42 @@ export const ChatComposer = memo(
1314 }1387 }
1315 return;1388 return;
1316 }1389 }
1390 if (item.type === "provider-slash-command") {
1391 const replacement = `/${item.command.name} `;
1392 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1393 snapshot.value,
1394 trigger.rangeEnd,
1395 replacement,
1396 );
1397 const applied = applyPromptReplacement(
1398 trigger.rangeStart,
1399 replacementRangeEnd,
1400 replacement,
1401 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1402 );
1403 if (applied) {
1404 setComposerHighlightedItemId(null);
1405 }
1406 return;
1407 }
1408 if (item.type === "skill") {
1409 const replacement = `$${item.skill.name} `;
1410 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1411 snapshot.value,
1412 trigger.rangeEnd,
1413 replacement,
1414 );
1415 const applied = applyPromptReplacement(
1416 trigger.rangeStart,
1417 replacementRangeEnd,
1418 replacement,
1419 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1420 );
1421 if (applied) {
1422 setComposerHighlightedItemId(null);
1423 }
1424 return;
1425 }
1317 onProviderModelSelect(item.provider, item.model);1426 onProviderModelSelect(item.provider, item.model);
1318 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {1427 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
1319 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),1428 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
...@@ -1330,9 +1439,13 @@ export const ChatComposer = memo(...@@ -1330,9 +1439,13 @@ export const ChatComposer = memo(
1330 ],1439 ],
1331 );1440 );
13321441
1333 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {1442 const onComposerMenuItemHighlighted = useCallback(
1334 setComposerHighlightedItemId(itemId);1443 (itemId: string | null) => {
1335 }, []);1444 setComposerHighlightedItemId(itemId);
1445 setComposerHighlightedSearchKey(composerMenuSearchKey);
1446 },
1447 [composerMenuSearchKey],
1448 );
13361449
1337 const nudgeComposerMenuHighlight = useCallback(1450 const nudgeComposerMenuHighlight = useCallback(
1338 (key: "ArrowDown" | "ArrowUp") => {1451 (key: "ArrowDown" | "ArrowUp") => {
...@@ -1657,6 +1770,11 @@ export const ChatComposer = memo(...@@ -1657,6 +1770,11 @@ export const ChatComposer = memo(
1657 resolvedTheme={resolvedTheme}1770 resolvedTheme={resolvedTheme}
1658 isLoading={isComposerMenuLoading}1771 isLoading={isComposerMenuLoading}
1659 triggerKind={composerTriggerKind}1772 triggerKind={composerTriggerKind}
1773 groupSlashCommandSections={
1774 composerTrigger?.kind === "slash-command" &&
1775 composerTrigger.query.trim().length === 0
1776 }
1777 emptyStateText={composerMenuEmptyState}
1660 activeItemId={activeComposerMenuItem?.id ?? null}1778 activeItemId={activeComposerMenuItem?.id ?? null}
1661 onHighlightedItemChange={onComposerMenuItemHighlighted}1779 onHighlightedItemChange={onComposerMenuItemHighlighted}
1662 onSelect={onSelectComposerItem}1780 onSelect={onSelectComposerItem}
...@@ -1746,6 +1864,7 @@ export const ChatComposer = memo(...@@ -1746,6 +1864,7 @@ export const ChatComposer = memo(
1746 ? composerTerminalContexts1864 ? composerTerminalContexts
1747 : []1865 : []
1748 }1866 }
1867 skills={selectedProviderStatus?.skills ?? []}
1749 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}1868 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
1750 onChange={onPromptChange}1869 onChange={onPromptChange}
1751 onCommandKeyDown={onComposerCommandKey}1870 onCommandKeyDown={onComposerCommandKey}
apps/web/src/components/chat/ComposerCommandMenu.tsx+161-27
...@@ -1,10 +1,24 @@...@@ -1,10 +1,24 @@
1import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts";1import {
2import { memo, useLayoutEffect, useRef } from "react";2 type ProjectEntry,
3import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";3 type ProviderKind,
4 type ServerProviderSkill,
5 type ServerProviderSlashCommand,
6} from "@t3tools/contracts";
4import { BotIcon } from "lucide-react";7import { BotIcon } from "lucide-react";
8import { memo, useLayoutEffect, useMemo, useRef } from "react";
9
10import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
11import { formatProviderSkillInstallSource } from "~/providerSkillPresentation";
5import { cn } from "~/lib/utils";12import { cn } from "~/lib/utils";
6import { Badge } from "../ui/badge";13import { Badge } from "../ui/badge";
7import { Command, CommandItem, CommandList } from "../ui/command";14import {
15 Command,
16 CommandGroup,
17 CommandGroupLabel,
18 CommandItem,
19 CommandList,
20 CommandSeparator,
21} from "../ui/command";
8import { VscodeEntryIcon } from "./VscodeEntryIcon";22import { VscodeEntryIcon } from "./VscodeEntryIcon";
923
10export type ComposerCommandItem =24export type ComposerCommandItem =
...@@ -23,6 +37,14 @@ export type ComposerCommandItem =...@@ -23,6 +37,14 @@ export type ComposerCommandItem =
23 label: string;37 label: string;
24 description: string;38 description: string;
25 }39 }
40 | {
41 id: string;
42 type: "provider-slash-command";
43 provider: ProviderKind;
44 command: ServerProviderSlashCommand;
45 label: string;
46 description: string;
47 }
26 | {48 | {
27 id: string;49 id: string;
28 type: "model";50 type: "model";
...@@ -30,18 +52,83 @@ export type ComposerCommandItem =...@@ -30,18 +52,83 @@ export type ComposerCommandItem =
30 model: string;52 model: string;
31 label: string;53 label: string;
32 description: string;54 description: string;
55 }
56 | {
57 id: string;
58 type: "skill";
59 provider: ProviderKind;
60 skill: ServerProviderSkill;
61 label: string;
62 description: string;
33 };63 };
3464
65type ComposerCommandGroup = {
66 id: string;
67 label: string | null;
68 items: ComposerCommandItem[];
69};
70
71function SkillGlyph(props: { className?: string }) {
72 return (
73 <svg
74 viewBox="0 0 24 24"
75 fill="none"
76 stroke="currentColor"
77 strokeWidth="1.85"
78 strokeLinecap="round"
79 strokeLinejoin="round"
80 className={props.className}
81 aria-hidden="true"
82 >
83 <path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
84 <path d="m3.3 7 8.7 5 8.7-5" />
85 <path d="M12 22V12" />
86 </svg>
87 );
88}
89
90function groupCommandItems(
91 items: ComposerCommandItem[],
92 triggerKind: ComposerTriggerKind | null,
93 groupSlashCommandSections: boolean,
94): ComposerCommandGroup[] {
95 if (triggerKind === "skill") {
96 return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : [];
97 }
98 if (triggerKind !== "slash-command" || !groupSlashCommandSections) {
99 return [{ id: "default", label: null, items }];
100 }
101
102 const builtInItems = items.filter((item) => item.type === "slash-command");
103 const providerItems = items.filter((item) => item.type === "provider-slash-command");
104
105 const groups: ComposerCommandGroup[] = [];
106 if (builtInItems.length > 0) {
107 groups.push({ id: "built-in", label: "Built-in", items: builtInItems });
108 }
109 if (providerItems.length > 0) {
110 groups.push({ id: "provider", label: "Provider", items: providerItems });
111 }
112 return groups;
113}
114
35export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {115export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
36 items: ComposerCommandItem[];116 items: ComposerCommandItem[];
37 resolvedTheme: "light" | "dark";117 resolvedTheme: "light" | "dark";
38 isLoading: boolean;118 isLoading: boolean;
39 triggerKind: ComposerTriggerKind | null;119 triggerKind: ComposerTriggerKind | null;
120 groupSlashCommandSections?: boolean;
121 emptyStateText?: string;
40 activeItemId: string | null;122 activeItemId: string | null;
41 onHighlightedItemChange: (itemId: string | null) => void;123 onHighlightedItemChange: (itemId: string | null) => void;
42 onSelect: (item: ComposerCommandItem) => void;124 onSelect: (item: ComposerCommandItem) => void;
43}) {125}) {
44 const listRef = useRef<HTMLDivElement>(null);126 const listRef = useRef<HTMLDivElement>(null);
127 const groups = useMemo(
128 () =>
129 groupCommandItems(props.items, props.triggerKind, props.groupSlashCommandSections ?? true),
130 [props.groupSlashCommandSections, props.items, props.triggerKind],
131 );
45132
46 useLayoutEffect(() => {133 useLayoutEffect(() => {
47 if (!props.activeItemId || !listRef.current) return;134 if (!props.activeItemId || !listRef.current) return;
...@@ -65,27 +152,56 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -65,27 +152,56 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
65 ref={listRef}152 ref={listRef}
66 className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"153 className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"
67 >154 >
68 <CommandList className="max-h-64">155 <CommandList className="max-h-72">
69 {props.items.map((item) => (156 {groups.map((group, groupIndex) => (
70 <ComposerCommandMenuItem157 <div key={group.id}>
71 key={item.id}158 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}
72 item={item}159 <CommandGroup>
73 resolvedTheme={props.resolvedTheme}160 {group.label ? (
74 isActive={props.activeItemId === item.id}161 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
75 onHighlight={props.onHighlightedItemChange}162 {group.label}
76 onSelect={props.onSelect}163 </CommandGroupLabel>
77 />164 ) : null}
165 {group.items.map((item) => (
166 <ComposerCommandMenuItem
167 key={item.id}
168 item={item}
169 resolvedTheme={props.resolvedTheme}
170 isActive={props.activeItemId === item.id}
171 onHighlight={props.onHighlightedItemChange}
172 onSelect={props.onSelect}
173 />
174 ))}
175 </CommandGroup>
176 </div>
78 ))}177 ))}
79 </CommandList>178 </CommandList>
80 {props.items.length === 0 && (179 {props.items.length === 0 ? (
81 <p className="px-3 py-2 text-muted-foreground/70 text-xs">180 <div className="px-3 py-2">
82 {props.isLoading181 {props.triggerKind === "skill" ? (
83 ? "Searching workspace files..."182 <CommandGroup>
84 : props.triggerKind === "path"183 <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
85 ? "No matching files or folders."184 Skills
86 : "No matching command."}185 </CommandGroupLabel>
87 </p>186 <p className="text-muted-foreground/70 text-xs">
88 )}187 {props.isLoading
188 ? "Searching workspace skills..."
189 : (props.emptyStateText ??
190 "No skills found. Try / to browse provider commands.")}
191 </p>
192 </CommandGroup>
193 ) : (
194 <p className="text-muted-foreground/70 text-xs">
195 {props.isLoading
196 ? "Searching workspace files..."
197 : (props.emptyStateText ??
198 (props.triggerKind === "path"
199 ? "No matching files or folders."
200 : "No matching command."))}
201 </p>
202 )}
203 </div>
204 ) : null}
89 </div>205 </div>
90 </Command>206 </Command>
91 );207 );
...@@ -98,6 +214,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -98,6 +214,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
98 onHighlight: (itemId: string | null) => void;214 onHighlight: (itemId: string | null) => void;
99 onSelect: (item: ComposerCommandItem) => void;215 onSelect: (item: ComposerCommandItem) => void;
100}) {216}) {
217 const skillSourceLabel =
218 props.item.type === "skill" ? formatProviderSkillInstallSource(props.item.skill) : null;
219
101 return (220 return (
102 <CommandItem221 <CommandItem
103 value={props.item.id}222 value={props.item.id}
...@@ -124,17 +243,32 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -124,17 +243,32 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
124 />243 />
125 ) : null}244 ) : null}
126 {props.item.type === "slash-command" ? (245 {props.item.type === "slash-command" ? (
127 <BotIcon className="size-4 text-muted-foreground/80" />246 <BotIcon className="size-4 shrink-0 text-muted-foreground/80" />
247 ) : null}
248 {props.item.type === "provider-slash-command" ? (
249 <span className="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground/80">
250 <SkillGlyph className="size-3.5" />
251 </span>
252 ) : null}
253 {props.item.type === "skill" ? (
254 <span className="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground/80">
255 <SkillGlyph className="size-3.5" />
256 </span>
128 ) : null}257 ) : null}
129 {props.item.type === "model" ? (258 {props.item.type === "model" ? (
130 <Badge variant="outline" className="px-1.5 py-0 text-[10px]">259 <Badge variant="outline" className="px-1.5 py-0 text-[10px]">
131 model260 model
132 </Badge>261 </Badge>
133 ) : null}262 ) : null}
134 <span className="flex min-w-0 items-center gap-1.5 truncate">263 <span className="flex min-w-0 flex-1 items-center gap-2">
135 <span className="truncate">{props.item.label}</span>264 <span className="shrink-0">{props.item.label}</span>
265 <span className="min-w-0 flex-1 truncate text-muted-foreground/70 text-xs">
266 {props.item.description}
267 </span>
136 </span>268 </span>
137 <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span>269 {skillSourceLabel ? (
270 <span className="shrink-0 pl-2 text-muted-foreground/70 text-xs">{skillSourceLabel}</span>
271 ) : null}
138 </CommandItem>272 </CommandItem>
139 );273 );
140});274});
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+6
...@@ -24,6 +24,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -24,6 +24,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
24 status: "ready",24 status: "ready",
25 auth: { status: "authenticated" },25 auth: { status: "authenticated" },
26 checkedAt: new Date().toISOString(),26 checkedAt: new Date().toISOString(),
27 slashCommands: [],
28 skills: [],
27 models: [29 models: [
28 {30 {
29 slug: "gpt-5-codex",31 slug: "gpt-5-codex",
...@@ -59,6 +61,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -59,6 +61,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
59 status: "ready",61 status: "ready",
60 auth: { status: "authenticated" },62 auth: { status: "authenticated" },
61 checkedAt: new Date().toISOString(),63 checkedAt: new Date().toISOString(),
64 slashCommands: [],
65 skills: [],
62 models: [66 models: [
63 {67 {
64 slug: "claude-opus-4-6",68 slug: "claude-opus-4-6",
...@@ -120,6 +124,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider {...@@ -120,6 +124,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider {
120 auth: { status: "authenticated" },124 auth: { status: "authenticated" },
121 checkedAt: new Date().toISOString(),125 checkedAt: new Date().toISOString(),
122 models,126 models,
127 slashCommands: [],
128 skills: [],
123 };129 };
124}130}
125131
apps/web/src/components/chat/TraitsPicker.browser.tsx+4
...@@ -44,6 +44,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -44,6 +44,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
44 status: "ready",44 status: "ready",
45 auth: { status: "authenticated" },45 auth: { status: "authenticated" },
46 checkedAt: "2026-01-01T00:00:00.000Z",46 checkedAt: "2026-01-01T00:00:00.000Z",
47 slashCommands: [],
48 skills: [],
47 models: [49 models: [
48 {50 {
49 slug: "gpt-5.4",51 slug: "gpt-5.4",
...@@ -70,6 +72,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -70,6 +72,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
70 status: "ready",72 status: "ready",
71 auth: { status: "authenticated" },73 auth: { status: "authenticated" },
72 checkedAt: "2026-01-01T00:00:00.000Z",74 checkedAt: "2026-01-01T00:00:00.000Z",
75 slashCommands: [],
76 skills: [],
73 models: [77 models: [
74 {78 {
75 slug: "claude-opus-4-6",79 slug: "claude-opus-4-6",
apps/web/src/components/chat/composerMenuHighlight.test.ts created+51
...@@ -0,0 +1,51 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
4
5describe("resolveComposerMenuActiveItemId", () => {
6 const items = [{ id: "top" }, { id: "second" }, { id: "third" }] as const;
7
8 it("defaults to the first item when nothing is highlighted", () => {
9 expect(
10 resolveComposerMenuActiveItemId({
11 items,
12 highlightedItemId: null,
13 currentSearchKey: "skill:u",
14 highlightedSearchKey: null,
15 }),
16 ).toBe("top");
17 });
18
19 it("preserves the highlighted item within the same query", () => {
20 expect(
21 resolveComposerMenuActiveItemId({
22 items,
23 highlightedItemId: "second",
24 currentSearchKey: "skill:u",
25 highlightedSearchKey: "skill:u",
26 }),
27 ).toBe("second");
28 });
29
30 it("resets to the top result when the query changes", () => {
31 expect(
32 resolveComposerMenuActiveItemId({
33 items,
34 highlightedItemId: "second",
35 currentSearchKey: "skill:ui",
36 highlightedSearchKey: "skill:u",
37 }),
38 ).toBe("top");
39 });
40
41 it("falls back to the first item when the highlighted item disappears", () => {
42 expect(
43 resolveComposerMenuActiveItemId({
44 items,
45 highlightedItemId: "missing",
46 currentSearchKey: "skill:ui",
47 highlightedSearchKey: "skill:ui",
48 }),
49 ).toBe("top");
50 });
51});
apps/web/src/components/chat/composerMenuHighlight.ts created+20
...@@ -0,0 +1,20 @@
1export function resolveComposerMenuActiveItemId(input: {
2 items: ReadonlyArray<{ id: string }>;
3 highlightedItemId: string | null;
4 currentSearchKey: string | null;
5 highlightedSearchKey: string | null;
6}): string | null {
7 if (input.items.length === 0) {
8 return null;
9 }
10
11 if (
12 input.currentSearchKey === input.highlightedSearchKey &&
13 input.highlightedItemId &&
14 input.items.some((item) => item.id === input.highlightedItemId)
15 ) {
16 return input.highlightedItemId;
17 }
18
19 return input.items[0]?.id ?? null;
20}
apps/web/src/components/chat/composerSlashCommandSearch.test.ts created+68
...@@ -0,0 +1,68 @@
1import { describe, expect, it } from "vitest";
2
3import type { ComposerCommandItem } from "./ComposerCommandMenu";
4import { searchSlashCommandItems } from "./composerSlashCommandSearch";
5
6describe("searchSlashCommandItems", () => {
7 it("moves exact provider command matches ahead of broader description matches", () => {
8 const items = [
9 {
10 id: "slash:default",
11 type: "slash-command",
12 command: "default",
13 label: "/default",
14 description: "Switch this thread back to normal build mode",
15 },
16 {
17 id: "provider-slash-command:claudeAgent:ui",
18 type: "provider-slash-command",
19 provider: "claudeAgent",
20 command: { name: "ui" },
21 label: "/ui",
22 description: "Explore, build, and refine UI.",
23 },
24 {
25 id: "provider-slash-command:claudeAgent:frontend-design",
26 type: "provider-slash-command",
27 provider: "claudeAgent",
28 command: { name: "frontend-design" },
29 label: "/frontend-design",
30 description: "Create distinctive, production-grade frontend interfaces",
31 },
32 ] satisfies Array<
33 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
34 >;
35
36 expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([
37 "provider-slash-command:claudeAgent:ui",
38 "slash:default",
39 ]);
40 });
41
42 it("supports fuzzy provider command matches", () => {
43 const items = [
44 {
45 id: "provider-slash-command:claudeAgent:gh-fix-ci",
46 type: "provider-slash-command",
47 provider: "claudeAgent",
48 command: { name: "gh-fix-ci" },
49 label: "/gh-fix-ci",
50 description: "Fix failing GitHub Actions",
51 },
52 {
53 id: "provider-slash-command:claudeAgent:github",
54 type: "provider-slash-command",
55 provider: "claudeAgent",
56 command: { name: "github" },
57 label: "/github",
58 description: "General GitHub help",
59 },
60 ] satisfies Array<
61 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
62 >;
63
64 expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([
65 "provider-slash-command:claudeAgent:gh-fix-ci",
66 ]);
67 });
68});
apps/web/src/components/chat/composerSlashCommandSearch.ts created+83
...@@ -0,0 +1,83 @@
1import {
2 insertRankedSearchResult,
3 normalizeSearchQuery,
4 scoreQueryMatch,
5} from "@t3tools/shared/searchRanking";
6
7import type { ComposerCommandItem } from "./ComposerCommandMenu";
8
9function scoreSlashCommandItem(
10 item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>,
11 query: string,
12): number | null {
13 const primaryValue =
14 item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase();
15 const description = item.description.toLowerCase();
16
17 const scores = [
18 scoreQueryMatch({
19 value: primaryValue,
20 query,
21 exactBase: 0,
22 prefixBase: 2,
23 boundaryBase: 4,
24 includesBase: 6,
25 fuzzyBase: 100,
26 boundaryMarkers: ["-", "_", "/"],
27 }),
28 scoreQueryMatch({
29 value: description,
30 query,
31 exactBase: 20,
32 prefixBase: 22,
33 boundaryBase: 24,
34 includesBase: 26,
35 }),
36 ].filter((score): score is number => score !== null);
37
38 if (scores.length === 0) {
39 return null;
40 }
41
42 return Math.min(...scores);
43}
44
45export function searchSlashCommandItems(
46 items: ReadonlyArray<
47 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
48 >,
49 query: string,
50): Array<Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>> {
51 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ });
52 if (!normalizedQuery) {
53 return [...items];
54 }
55
56 const ranked: Array<{
57 item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>;
58 score: number;
59 tieBreaker: string;
60 }> = [];
61
62 for (const item of items) {
63 const score = scoreSlashCommandItem(item, normalizedQuery);
64 if (score === null) {
65 continue;
66 }
67
68 insertRankedSearchResult(
69 ranked,
70 {
71 item,
72 score,
73 tieBreaker:
74 item.type === "slash-command"
75 ? `0\u0000${item.command}`
76 : `1\u0000${item.command.name}\u0000${item.provider}`,
77 },
78 Number.POSITIVE_INFINITY,
79 );
80 }
81
82 return ranked.map((entry) => entry.item);
83}
apps/web/src/components/composerInlineChip.ts+3
...@@ -5,5 +5,8 @@ export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-8...@@ -5,5 +5,8 @@ export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-8
55
6export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight";6export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight";
77
8export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
9 "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";
10
8export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =11export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =
9 "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";12 "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";
apps/web/src/composer-editor-mentions.test.ts+29
...@@ -29,6 +29,20 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -29,6 +29,20 @@ describe("splitPromptIntoComposerSegments", () => {
29 ]);29 ]);
30 });30 });
3131
32 it("splits skill tokens followed by whitespace into skill segments", () => {
33 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
34 { type: "text", text: "Use " },
35 { type: "skill", name: "review-follow-up" },
36 { type: "text", text: " please" },
37 ]);
38 });
39
40 it("does not convert an incomplete trailing skill token", () => {
41 expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([
42 { type: "text", text: "Use $review-follow-up" },
43 ]);
44 });
45
32 it("keeps inline terminal context placeholders at their prompt positions", () => {46 it("keeps inline terminal context placeholders at their prompt positions", () => {
33 expect(47 expect(
34 splitPromptIntoComposerSegments(48 splitPromptIntoComposerSegments(
...@@ -53,6 +67,21 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -53,6 +67,21 @@ describe("splitPromptIntoComposerSegments", () => {
53 { type: "text", text: "tail" },67 { type: "text", text: "tail" },
54 ]);68 ]);
55 });69 });
70
71 it("keeps skill parsing alongside mentions and terminal placeholders", () => {
72 expect(
73 splitPromptIntoComposerSegments(
74 `Inspect ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}$review-follow-up after @AGENTS.md `,
75 ),
76 ).toEqual([
77 { type: "text", text: "Inspect " },
78 { type: "terminal-context", context: null },
79 { type: "skill", name: "review-follow-up" },
80 { type: "text", text: " after " },
81 { type: "mention", path: "AGENTS.md" },
82 { type: "text", text: " " },
83 ]);
84 });
56});85});
5786
58describe("selectionTouchesMentionBoundary", () => {87describe("selectionTouchesMentionBoundary", () => {
apps/web/src/composer-editor-mentions.ts+60-13
...@@ -12,12 +12,17 @@ export type ComposerPromptSegment =...@@ -12,12 +12,17 @@ export type ComposerPromptSegment =
12 type: "mention";12 type: "mention";
13 path: string;13 path: string;
14 }14 }
15 | {
16 type: "skill";
17 name: string;
18 }
15 | {19 | {
16 type: "terminal-context";20 type: "terminal-context";
17 context: TerminalContextDraft | null;21 context: TerminalContextDraft | null;
18 };22 };
1923
20const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;24const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
25const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
2126
22function rangeIncludesIndex(start: number, end: number, index: number): boolean {27function rangeIncludesIndex(start: number, end: number, index: number): boolean {
23 return start <= index && index < end;28 return start <= index && index < end;
...@@ -33,6 +38,50 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void...@@ -33,6 +38,50 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void
33 segments.push({ type: "text", text });38 segments.push({ type: "text", text });
34}39}
3540
41type InlineTokenMatch =
42 | {
43 type: "mention";
44 value: string;
45 start: number;
46 end: number;
47 }
48 | {
49 type: "skill";
50 value: string;
51 start: number;
52 end: number;
53 };
54
55function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
56 const matches: InlineTokenMatch[] = [];
57
58 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
59 const fullMatch = match[0];
60 const prefix = match[1] ?? "";
61 const path = match[2] ?? "";
62 const matchIndex = match.index ?? 0;
63 const start = matchIndex + prefix.length;
64 const end = start + fullMatch.length - prefix.length;
65 if (path.length > 0) {
66 matches.push({ type: "mention", value: path, start, end });
67 }
68 }
69
70 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
71 const fullMatch = match[0];
72 const prefix = match[1] ?? "";
73 const skillName = match[2] ?? "";
74 const matchIndex = match.index ?? 0;
75 const start = matchIndex + prefix.length;
76 const end = start + fullMatch.length - prefix.length;
77 if (skillName.length > 0) {
78 matches.push({ type: "skill", value: skillName, start, end });
79 }
80 }
81
82 return matches.toSorted((left, right) => left.start - right.start);
83}
84
36function forEachPromptSegmentSlice(85function forEachPromptSegmentSlice(
37 prompt: string,86 prompt: string,
38 visitor: (87 visitor: (
...@@ -117,26 +166,24 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen...@@ -117,26 +166,24 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
117 return segments;166 return segments;
118 }167 }
119168
169 const tokenMatches = collectInlineTokenMatches(text);
120 let cursor = 0;170 let cursor = 0;
121 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {171 for (const match of tokenMatches) {
122 const fullMatch = match[0];172 if (match.start < cursor) {
123 const prefix = match[1] ?? "";173 continue;
124 const path = match[2] ?? "";174 }
125 const matchIndex = match.index ?? 0;
126 const mentionStart = matchIndex + prefix.length;
127 const mentionEnd = mentionStart + fullMatch.length - prefix.length;
128175
129 if (mentionStart > cursor) {176 if (match.start > cursor) {
130 pushTextSegment(segments, text.slice(cursor, mentionStart));177 pushTextSegment(segments, text.slice(cursor, match.start));
131 }178 }
132179
133 if (path.length > 0) {180 if (match.type === "mention") {
134 segments.push({ type: "mention", path });181 segments.push({ type: "mention", path: match.value });
135 } else {182 } else {
136 pushTextSegment(segments, text.slice(mentionStart, mentionEnd));183 segments.push({ type: "skill", name: match.value });
137 }184 }
138185
139 cursor = mentionEnd;186 cursor = match.end;
140 }187 }
141188
142 if (cursor < text.length) {189 if (cursor < text.length) {
apps/web/src/composer-logic.test.ts+53
...@@ -60,6 +60,30 @@ describe("detectComposerTrigger", () => {...@@ -60,6 +60,30 @@ describe("detectComposerTrigger", () => {
60 });60 });
61 });61 });
6262
63 it("keeps slash command detection active for provider commands", () => {
64 const text = "/rev";
65 const trigger = detectComposerTrigger(text, text.length);
66
67 expect(trigger).toEqual({
68 kind: "slash-command",
69 query: "rev",
70 rangeStart: 0,
71 rangeEnd: text.length,
72 });
73 });
74
75 it("detects $skill trigger at cursor", () => {
76 const text = "Use $gh-fi";
77 const trigger = detectComposerTrigger(text, text.length);
78
79 expect(trigger).toEqual({
80 kind: "skill",
81 query: "gh-fi",
82 rangeStart: "Use ".length,
83 rangeEnd: text.length,
84 });
85 });
86
63 it("detects @path trigger in the middle of existing text", () => {87 it("detects @path trigger in the middle of existing text", () => {
64 // User typed @ between "inspect " and "in this sentence"88 // User typed @ between "inspect " and "in this sentence"
65 const text = "Please inspect @in this sentence";89 const text = "Please inspect @in this sentence";
...@@ -133,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => {...@@ -133,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => {
133157
134 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();158 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
135 });159 });
160
161 it("maps collapsed skill cursor to expanded text cursor", () => {
162 const text = "run $review-follow-up then";
163 const collapsedCursorAfterSkill = "run ".length + 2;
164 const expandedCursorAfterSkill = "run $review-follow-up ".length;
165
166 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe(
167 expandedCursorAfterSkill,
168 );
169 });
136});170});
137171
138describe("collapseExpandedComposerCursor", () => {172describe("collapseExpandedComposerCursor", () => {
...@@ -158,6 +192,16 @@ describe("collapseExpandedComposerCursor", () => {...@@ -158,6 +192,16 @@ describe("collapseExpandedComposerCursor", () => {
158 expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2);192 expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2);
159 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);193 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
160 });194 });
195
196 it("maps expanded skill cursor back to collapsed cursor", () => {
197 const text = "run $review-follow-up then";
198 const collapsedCursorAfterSkill = "run ".length + 2;
199 const expandedCursorAfterSkill = "run $review-follow-up ".length;
200
201 expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe(
202 collapsedCursorAfterSkill,
203 );
204 });
161});205});
162206
163describe("clampCollapsedComposerCursor", () => {207describe("clampCollapsedComposerCursor", () => {
...@@ -233,6 +277,15 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => {...@@ -233,6 +277,15 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => {
233 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true);277 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true);
234 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true);278 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true);
235 });279 });
280
281 it("treats skill pills as inline tokens for adjacency checks", () => {
282 const text = "run $review-follow-up next";
283 const tokenStart = "run ".length;
284 const tokenEnd = tokenStart + 1;
285
286 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true);
287 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true);
288 });
236});289});
237290
238describe("parseStandaloneComposerSlashCommand", () => {291describe("parseStandaloneComposerSlashCommand", () => {
apps/web/src/composer-logic.ts+50-14
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";2import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
33
4export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";4export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill";
5export type ComposerSlashCommand = "model" | "plan" | "default";5export type ComposerSlashCommand = "model" | "plan" | "default";
66
7export interface ComposerTrigger {7export interface ComposerTrigger {
...@@ -11,9 +11,12 @@ export interface ComposerTrigger {...@@ -11,9 +11,12 @@ export interface ComposerTrigger {
11 rangeEnd: number;11 rangeEnd: number;
12}12}
1313
14const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
15const isInlineTokenSegment = (14const isInlineTokenSegment = (
16 segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" },15 segment:
16 | { type: "text"; text: string }
17 | { type: "mention" }
18 | { type: "skill" }
19 | { type: "terminal-context" },
17): boolean => segment.type !== "text";20): boolean => segment.type !== "text";
1821
19function clampCursor(text: string, cursor: number): number {22function clampCursor(text: string, cursor: number): number {
...@@ -59,6 +62,15 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)...@@ -59,6 +62,15 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
59 expandedCursor += expandedLength;62 expandedCursor += expandedLength;
60 continue;63 continue;
61 }64 }
65 if (segment.type === "skill") {
66 const expandedLength = segment.name.length + 1;
67 if (remaining <= 1) {
68 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
69 }
70 remaining -= 1;
71 expandedCursor += expandedLength;
72 continue;
73 }
62 if (segment.type === "terminal-context") {74 if (segment.type === "terminal-context") {
63 if (remaining <= 1) {75 if (remaining <= 1) {
64 return expandedCursor + remaining;76 return expandedCursor + remaining;
...@@ -80,7 +92,11 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)...@@ -80,7 +92,11 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
80}92}
8193
82function collapsedSegmentLength(94function collapsedSegmentLength(
83 segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" },95 segment:
96 | { type: "text"; text: string }
97 | { type: "mention" }
98 | { type: "skill" }
99 | { type: "terminal-context" },
84): number {100): number {
85 if (segment.type === "text") {101 if (segment.type === "text") {
86 return segment.text.length;102 return segment.text.length;
...@@ -90,7 +106,10 @@ function collapsedSegmentLength(...@@ -90,7 +106,10 @@ function collapsedSegmentLength(
90106
91function clampCollapsedComposerCursorForSegments(107function clampCollapsedComposerCursorForSegments(
92 segments: ReadonlyArray<108 segments: ReadonlyArray<
93 { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }109 | { type: "text"; text: string }
110 | { type: "mention" }
111 | { type: "skill" }
112 | { type: "terminal-context" }
94 >,113 >,
95 cursorInput: number,114 cursorInput: number,
96): number {115): number {
...@@ -134,6 +153,18 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number...@@ -134,6 +153,18 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
134 collapsedCursor += 1;153 collapsedCursor += 1;
135 continue;154 continue;
136 }155 }
156 if (segment.type === "skill") {
157 const expandedLength = segment.name.length + 1;
158 if (remaining === 0) {
159 return collapsedCursor;
160 }
161 if (remaining <= expandedLength) {
162 return collapsedCursor + 1;
163 }
164 remaining -= expandedLength;
165 collapsedCursor += 1;
166 continue;
167 }
137 if (segment.type === "terminal-context") {168 if (segment.type === "terminal-context") {
138 if (remaining <= 1) {169 if (remaining <= 1) {
139 return collapsedCursor + remaining;170 return collapsedCursor + remaining;
...@@ -201,15 +232,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos...@@ -201,15 +232,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
201 rangeEnd: cursor,232 rangeEnd: cursor,
202 };233 };
203 }234 }
204 if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) {235 return {
205 return {236 kind: "slash-command",
206 kind: "slash-command",237 query: commandQuery,
207 query: commandQuery,238 rangeStart: lineStart,
208 rangeStart: lineStart,239 rangeEnd: cursor,
209 rangeEnd: cursor,240 };
210 };
211 }
212 return null;
213 }241 }
214242
215 const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix);243 const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix);
...@@ -225,6 +253,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos...@@ -225,6 +253,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
225253
226 const tokenStart = tokenStartForCursor(text, cursor);254 const tokenStart = tokenStartForCursor(text, cursor);
227 const token = text.slice(tokenStart, cursor);255 const token = text.slice(tokenStart, cursor);
256 if (token.startsWith("$")) {
257 return {
258 kind: "skill",
259 query: token.slice(1),
260 rangeStart: tokenStart,
261 rangeEnd: cursor,
262 };
263 }
228 if (!token.startsWith("@")) {264 if (!token.startsWith("@")) {
229 return null;265 return null;
230 }266 }
apps/web/src/localApi.test.ts+2
...@@ -210,6 +210,8 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [...@@ -210,6 +210,8 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [
210 auth: { status: "authenticated" },210 auth: { status: "authenticated" },
211 checkedAt: "2026-01-01T00:00:00.000Z",211 checkedAt: "2026-01-01T00:00:00.000Z",
212 models: [],212 models: [],
213 slashCommands: [],
214 skills: [],
213 },215 },
214];216];
215217
apps/web/src/providerSkillPresentation.test.ts created+57
...@@ -0,0 +1,57 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 formatProviderSkillDisplayName,
5 formatProviderSkillInstallSource,
6} from "./providerSkillPresentation";
7
8describe("formatProviderSkillDisplayName", () => {
9 it("prefers the provider display name", () => {
10 expect(
11 formatProviderSkillDisplayName({
12 name: "review-follow-up",
13 displayName: "Review Follow-up",
14 }),
15 ).toBe("Review Follow-up");
16 });
17
18 it("falls back to a title-cased skill name", () => {
19 expect(
20 formatProviderSkillDisplayName({
21 name: "review-follow-up",
22 }),
23 ).toBe("Review Follow Up");
24 });
25});
26
27describe("formatProviderSkillInstallSource", () => {
28 it("marks plugin-backed skills as app installs", () => {
29 expect(
30 formatProviderSkillInstallSource({
31 path: "/Users/julius/.codex/plugins/cache/openai-curated/github/skills/gh-fix-ci/SKILL.md",
32 scope: "user",
33 }),
34 ).toBe("App");
35 });
36
37 it("maps standard scopes to user-facing labels", () => {
38 expect(
39 formatProviderSkillInstallSource({
40 path: "/Users/julius/.agents/skills/agent-browser/SKILL.md",
41 scope: "user",
42 }),
43 ).toBe("Personal");
44 expect(
45 formatProviderSkillInstallSource({
46 path: "/usr/local/share/codex/skills/imagegen/SKILL.md",
47 scope: "system",
48 }),
49 ).toBe("System");
50 expect(
51 formatProviderSkillInstallSource({
52 path: "/workspace/.codex/skills/review-follow-up/SKILL.md",
53 scope: "project",
54 }),
55 ).toBe("Project");
56 });
57});
apps/web/src/providerSkillPresentation.ts created+52
...@@ -0,0 +1,52 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";
2
3function titleCaseWords(value: string): string {
4 return value
5 .split(/[\s:_-]+/)
6 .filter((segment) => segment.length > 0)
7 .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
8 .join(" ");
9}
10
11function normalizePathSeparators(pathValue: string): string {
12 return pathValue.replaceAll("\\", "/");
13}
14
15export function formatProviderSkillDisplayName(
16 skill: Pick<ServerProviderSkill, "name" | "displayName">,
17): string {
18 const displayName = skill.displayName?.trim();
19 if (displayName) {
20 return displayName;
21 }
22 return titleCaseWords(skill.name);
23}
24
25export function formatProviderSkillInstallSource(
26 skill: Pick<ServerProviderSkill, "path" | "scope">,
27): string | null {
28 const normalizedPath = normalizePathSeparators(skill.path);
29 if (normalizedPath.includes("/.codex/plugins/") || normalizedPath.includes("/.agents/plugins/")) {
30 return "App";
31 }
32
33 const normalizedScope = skill.scope?.trim().toLowerCase();
34 if (normalizedScope === "system") {
35 return "System";
36 }
37 if (
38 normalizedScope === "project" ||
39 normalizedScope === "workspace" ||
40 normalizedScope === "local"
41 ) {
42 return "Project";
43 }
44 if (normalizedScope === "user" || normalizedScope === "personal") {
45 return "Personal";
46 }
47 if (normalizedScope) {
48 return titleCaseWords(normalizedScope);
49 }
50
51 return null;
52}
apps/web/src/providerSkillSearch.test.ts created+59
...@@ -0,0 +1,59 @@
1import { describe, expect, it } from "vitest";
2
3import type { ServerProviderSkill } from "@t3tools/contracts";
4
5import { searchProviderSkills } from "./providerSkillSearch";
6
7function makeSkill(input: Partial<ServerProviderSkill> & Pick<ServerProviderSkill, "name">) {
8 return {
9 path: `/tmp/${input.name}/SKILL.md`,
10 enabled: true,
11 ...input,
12 } satisfies ServerProviderSkill;
13}
14
15describe("searchProviderSkills", () => {
16 it("moves exact ui matches ahead of broader ui matches", () => {
17 const skills = [
18 makeSkill({
19 name: "agent-browser",
20 displayName: "Agent Browser",
21 shortDescription: "Browser automation CLI for AI agents",
22 }),
23 makeSkill({
24 name: "building-native-ui",
25 displayName: "Building Native Ui",
26 shortDescription: "Complete guide for building beautiful apps with Expo Router",
27 }),
28 makeSkill({
29 name: "ui",
30 displayName: "Ui",
31 shortDescription: "Explore, build, and refine UI.",
32 }),
33 ];
34
35 expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([
36 "ui",
37 "building-native-ui",
38 ]);
39 });
40
41 it("uses fuzzy ranking for abbreviated queries", () => {
42 const skills = [
43 makeSkill({ name: "gh-fix-ci", displayName: "Gh Fix Ci" }),
44 makeSkill({ name: "github", displayName: "Github" }),
45 makeSkill({ name: "agent-browser", displayName: "Agent Browser" }),
46 ];
47
48 expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]);
49 });
50
51 it("omits disabled skills from results", () => {
52 const skills = [
53 makeSkill({ name: "ui", displayName: "Ui", enabled: false }),
54 makeSkill({ name: "frontend-design", displayName: "Frontend Design" }),
55 ];
56
57 expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]);
58 });
59});
apps/web/src/providerSkillSearch.ts created+105
...@@ -0,0 +1,105 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";
2import {
3 insertRankedSearchResult,
4 normalizeSearchQuery,
5 scoreQueryMatch,
6} from "@t3tools/shared/searchRanking";
7
8import { formatProviderSkillDisplayName } from "./providerSkillPresentation";
9
10function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null {
11 const normalizedName = skill.name.toLowerCase();
12 const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase();
13 const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? "";
14 const normalizedDescription = skill.description?.toLowerCase() ?? "";
15 const normalizedScope = skill.scope?.toLowerCase() ?? "";
16
17 const scores = [
18 scoreQueryMatch({
19 value: normalizedName,
20 query,
21 exactBase: 0,
22 prefixBase: 2,
23 boundaryBase: 4,
24 includesBase: 6,
25 fuzzyBase: 100,
26 boundaryMarkers: ["-", "_", "/"],
27 }),
28 scoreQueryMatch({
29 value: normalizedLabel,
30 query,
31 exactBase: 1,
32 prefixBase: 3,
33 boundaryBase: 5,
34 includesBase: 7,
35 fuzzyBase: 110,
36 }),
37 scoreQueryMatch({
38 value: normalizedShortDescription,
39 query,
40 exactBase: 20,
41 prefixBase: 22,
42 boundaryBase: 24,
43 includesBase: 26,
44 }),
45 scoreQueryMatch({
46 value: normalizedDescription,
47 query,
48 exactBase: 30,
49 prefixBase: 32,
50 boundaryBase: 34,
51 includesBase: 36,
52 }),
53 scoreQueryMatch({
54 value: normalizedScope,
55 query,
56 exactBase: 40,
57 prefixBase: 42,
58 includesBase: 44,
59 }),
60 ].filter((score): score is number => score !== null);
61
62 if (scores.length === 0) {
63 return null;
64 }
65
66 return Math.min(...scores);
67}
68
69export function searchProviderSkills(
70 skills: ReadonlyArray<ServerProviderSkill>,
71 query: string,
72 limit = Number.POSITIVE_INFINITY,
73): ServerProviderSkill[] {
74 const enabledSkills = skills.filter((skill) => skill.enabled);
75 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });
76
77 if (!normalizedQuery) {
78 return enabledSkills;
79 }
80
81 const ranked: Array<{
82 item: ServerProviderSkill;
83 score: number;
84 tieBreaker: string;
85 }> = [];
86
87 for (const skill of enabledSkills) {
88 const score = scoreProviderSkill(skill, normalizedQuery);
89 if (score === null) {
90 continue;
91 }
92
93 insertRankedSearchResult(
94 ranked,
95 {
96 item: skill,
97 score,
98 tieBreaker: `${formatProviderSkillDisplayName(skill).toLowerCase()}\u0000${skill.name}`,
99 },
100 limit,
101 );
102 }
103
104 return ranked.map((entry) => entry.item);
105}
apps/web/src/rpc/serverState.test.ts+2
...@@ -48,6 +48,8 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [...@@ -48,6 +48,8 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [
48 auth: { status: "authenticated" },48 auth: { status: "authenticated" },
49 checkedAt: "2026-01-01T00:00:00.000Z",49 checkedAt: "2026-01-01T00:00:00.000Z",
50 models: [],50 models: [],
51 slashCommands: [],
52 skills: [],
51 },53 },
52];54];
5355
packages/contracts/src/server.test.ts created+26
...@@ -0,0 +1,26 @@
1import { Schema } from "effect";
2import { describe, expect, it } from "vitest";
3
4import { ServerProvider } from "./server";
5
6const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
7
8describe("ServerProvider", () => {
9 it("defaults capability arrays when decoding legacy snapshots", () => {
10 const parsed = decodeServerProvider({
11 provider: "codex",
12 enabled: true,
13 installed: true,
14 version: "1.0.0",
15 status: "ready",
16 auth: {
17 status: "authenticated",
18 },
19 checkedAt: "2026-04-10T00:00:00.000Z",
20 models: [],
21 });
22
23 expect(parsed.slashCommands).toEqual([]);
24 expect(parsed.skills).toEqual([]);
25 });
26});
packages/contracts/src/server.ts+28-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import { Effect, Schema } from "effect";
2import { ExecutionEnvironmentDescriptor } from "./environment";2import { ExecutionEnvironmentDescriptor } from "./environment";
3import { ServerAuthDescriptor } from "./auth";3import { ServerAuthDescriptor } from "./auth";
4import {4import {
...@@ -58,6 +58,29 @@ export const ServerProviderModel = Schema.Struct({...@@ -58,6 +58,29 @@ export const ServerProviderModel = Schema.Struct({
58});58});
59export type ServerProviderModel = typeof ServerProviderModel.Type;59export type ServerProviderModel = typeof ServerProviderModel.Type;
6060
61export const ServerProviderSlashCommandInput = Schema.Struct({
62 hint: TrimmedNonEmptyString,
63});
64export type ServerProviderSlashCommandInput = typeof ServerProviderSlashCommandInput.Type;
65
66export const ServerProviderSlashCommand = Schema.Struct({
67 name: TrimmedNonEmptyString,
68 description: Schema.optional(TrimmedNonEmptyString),
69 input: Schema.optional(ServerProviderSlashCommandInput),
70});
71export type ServerProviderSlashCommand = typeof ServerProviderSlashCommand.Type;
72
73export const ServerProviderSkill = Schema.Struct({
74 name: TrimmedNonEmptyString,
75 description: Schema.optional(TrimmedNonEmptyString),
76 path: TrimmedNonEmptyString,
77 scope: Schema.optional(TrimmedNonEmptyString),
78 enabled: Schema.Boolean,
79 displayName: Schema.optional(TrimmedNonEmptyString),
80 shortDescription: Schema.optional(TrimmedNonEmptyString),
81});
82export type ServerProviderSkill = typeof ServerProviderSkill.Type;
83
61export const ServerProvider = Schema.Struct({84export const ServerProvider = Schema.Struct({
62 provider: ProviderKind,85 provider: ProviderKind,
63 enabled: Schema.Boolean,86 enabled: Schema.Boolean,
...@@ -68,6 +91,10 @@ export const ServerProvider = Schema.Struct({...@@ -68,6 +91,10 @@ export const ServerProvider = Schema.Struct({
68 checkedAt: IsoDateTime,91 checkedAt: IsoDateTime,
69 message: Schema.optional(TrimmedNonEmptyString),92 message: Schema.optional(TrimmedNonEmptyString),
70 models: Schema.Array(ServerProviderModel),93 models: Schema.Array(ServerProviderModel),
94 slashCommands: Schema.Array(ServerProviderSlashCommand).pipe(
95 Schema.withDecodingDefault(Effect.succeed([])),
96 ),
97 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
71});98});
72export type ServerProvider = typeof ServerProvider.Type;99export type ServerProvider = typeof ServerProvider.Type;
73100
packages/shared/package.json+4
...@@ -52,6 +52,10 @@...@@ -52,6 +52,10 @@
52 "types": "./src/projectScripts.ts",52 "types": "./src/projectScripts.ts",
53 "import": "./src/projectScripts.ts"53 "import": "./src/projectScripts.ts"
54 },54 },
55 "./searchRanking": {
56 "types": "./src/searchRanking.ts",
57 "import": "./src/searchRanking.ts"
58 },
55 "./qrCode": {59 "./qrCode": {
56 "types": "./src/qrCode.ts",60 "types": "./src/qrCode.ts",
57 "import": "./src/qrCode.ts"61 "import": "./src/qrCode.ts"
packages/shared/src/searchRanking.test.ts created+95
...@@ -0,0 +1,95 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 compareRankedSearchResults,
5 insertRankedSearchResult,
6 normalizeSearchQuery,
7 scoreQueryMatch,
8 scoreSubsequenceMatch,
9} from "./searchRanking";
10
11describe("normalizeSearchQuery", () => {
12 it("trims and lowercases queries", () => {
13 expect(normalizeSearchQuery(" UI ")).toBe("ui");
14 });
15
16 it("can strip leading trigger characters", () => {
17 expect(normalizeSearchQuery(" $ui", { trimLeadingPattern: /^\$+/ })).toBe("ui");
18 });
19});
20
21describe("scoreQueryMatch", () => {
22 it("prefers exact matches over broader contains matches", () => {
23 expect(
24 scoreQueryMatch({
25 value: "ui",
26 query: "ui",
27 exactBase: 0,
28 prefixBase: 10,
29 includesBase: 20,
30 }),
31 ).toBe(0);
32
33 expect(
34 scoreQueryMatch({
35 value: "building native ui",
36 query: "ui",
37 exactBase: 0,
38 prefixBase: 10,
39 boundaryBase: 20,
40 includesBase: 30,
41 }),
42 ).toBeGreaterThan(0);
43 });
44
45 it("treats boundary matches as stronger than generic contains matches", () => {
46 const boundaryScore = scoreQueryMatch({
47 value: "gh-fix-ci",
48 query: "fix",
49 exactBase: 0,
50 prefixBase: 10,
51 boundaryBase: 20,
52 includesBase: 30,
53 boundaryMarkers: ["-"],
54 });
55 const containsScore = scoreQueryMatch({
56 value: "highfixci",
57 query: "fix",
58 exactBase: 0,
59 prefixBase: 10,
60 boundaryBase: 20,
61 includesBase: 30,
62 boundaryMarkers: ["-"],
63 });
64
65 expect(boundaryScore).not.toBeNull();
66 expect(containsScore).not.toBeNull();
67 expect(boundaryScore!).toBeLessThan(containsScore!);
68 });
69});
70
71describe("scoreSubsequenceMatch", () => {
72 it("scores tighter subsequences ahead of looser ones", () => {
73 const compact = scoreSubsequenceMatch("ghfixci", "gfc");
74 const spread = scoreSubsequenceMatch("github-fix-ci", "gfc");
75
76 expect(compact).not.toBeNull();
77 expect(spread).not.toBeNull();
78 expect(compact!).toBeLessThan(spread!);
79 });
80});
81
82describe("insertRankedSearchResult", () => {
83 it("keeps the best-ranked candidates within the limit", () => {
84 const ranked = [
85 { item: "b", score: 20, tieBreaker: "b" },
86 { item: "d", score: 40, tieBreaker: "d" },
87 ];
88
89 insertRankedSearchResult(ranked, { item: "a", score: 10, tieBreaker: "a" }, 2);
90 insertRankedSearchResult(ranked, { item: "c", score: 30, tieBreaker: "c" }, 2);
91
92 expect(ranked.map((entry) => entry.item)).toEqual(["a", "b"]);
93 expect(compareRankedSearchResults(ranked[0]!, ranked[1]!)).toBeLessThan(0);
94 });
95});
packages/shared/src/searchRanking.ts created+192
...@@ -0,0 +1,192 @@
1export type RankedSearchResult<T> = {
2 item: T;
3 score: number;
4 tieBreaker: string;
5};
6
7export function normalizeSearchQuery(
8 input: string,
9 options?: {
10 trimLeadingPattern?: RegExp;
11 },
12): string {
13 const trimmed = input.trim();
14 if (!trimmed) {
15 return "";
16 }
17 return options?.trimLeadingPattern
18 ? trimmed.replace(options.trimLeadingPattern, "").toLowerCase()
19 : trimmed.toLowerCase();
20}
21
22export function scoreSubsequenceMatch(value: string, query: string): number | null {
23 if (!query) return 0;
24
25 let queryIndex = 0;
26 let firstMatchIndex = -1;
27 let previousMatchIndex = -1;
28 let gapPenalty = 0;
29
30 for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) {
31 if (value[valueIndex] !== query[queryIndex]) {
32 continue;
33 }
34
35 if (firstMatchIndex === -1) {
36 firstMatchIndex = valueIndex;
37 }
38 if (previousMatchIndex !== -1) {
39 gapPenalty += valueIndex - previousMatchIndex - 1;
40 }
41
42 previousMatchIndex = valueIndex;
43 queryIndex += 1;
44 if (queryIndex === query.length) {
45 const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length;
46 const lengthPenalty = Math.min(64, value.length - query.length);
47 return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty;
48 }
49 }
50
51 return null;
52}
53
54function lengthPenalty(value: string, query: string): number {
55 return Math.min(64, Math.max(0, value.length - query.length));
56}
57
58function findBoundaryMatchIndex(
59 value: string,
60 query: string,
61 boundaryMarkers: readonly string[],
62): number | null {
63 let bestIndex: number | null = null;
64
65 for (const marker of boundaryMarkers) {
66 const index = value.indexOf(`${marker}${query}`);
67 if (index === -1) {
68 continue;
69 }
70
71 const matchIndex = index + marker.length;
72 if (bestIndex === null || matchIndex < bestIndex) {
73 bestIndex = matchIndex;
74 }
75 }
76
77 return bestIndex;
78}
79
80/**
81 * Scores how well `value` matches `query` using tiered match strategies.
82 *
83 * **Expects pre-normalized inputs**: both `value` and `query` must already be
84 * trimmed and lowercased (e.g. via {@link normalizeSearchQuery}).
85 */
86export function scoreQueryMatch(input: {
87 value: string;
88 query: string;
89 exactBase: number;
90 prefixBase?: number;
91 boundaryBase?: number;
92 includesBase?: number;
93 fuzzyBase?: number;
94 boundaryMarkers?: readonly string[];
95}): number | null {
96 const { value, query } = input;
97
98 if (!value || !query) {
99 return null;
100 }
101
102 if (value === query) {
103 return input.exactBase;
104 }
105
106 if (input.prefixBase !== undefined && value.startsWith(query)) {
107 return input.prefixBase + lengthPenalty(value, query);
108 }
109
110 if (input.boundaryBase !== undefined) {
111 const boundaryIndex = findBoundaryMatchIndex(
112 value,
113 query,
114 input.boundaryMarkers ?? [" ", "-", "_", "/"],
115 );
116 if (boundaryIndex !== null) {
117 return input.boundaryBase + boundaryIndex * 2 + lengthPenalty(value, query);
118 }
119 }
120
121 if (input.includesBase !== undefined) {
122 const includesIndex = value.indexOf(query);
123 if (includesIndex !== -1) {
124 return input.includesBase + includesIndex * 2 + lengthPenalty(value, query);
125 }
126 }
127
128 if (input.fuzzyBase !== undefined) {
129 const fuzzyScore = scoreSubsequenceMatch(value, query);
130 if (fuzzyScore !== null) {
131 return input.fuzzyBase + fuzzyScore;
132 }
133 }
134
135 return null;
136}
137
138export function compareRankedSearchResults<T>(
139 left: RankedSearchResult<T>,
140 right: RankedSearchResult<T>,
141): number {
142 const scoreDelta = left.score - right.score;
143 if (scoreDelta !== 0) return scoreDelta;
144 return left.tieBreaker.localeCompare(right.tieBreaker);
145}
146
147function findInsertionIndex<T>(
148 rankedEntries: RankedSearchResult<T>[],
149 candidate: RankedSearchResult<T>,
150): number {
151 let low = 0;
152 let high = rankedEntries.length;
153
154 while (low < high) {
155 const middle = low + Math.floor((high - low) / 2);
156 const current = rankedEntries[middle];
157 if (!current) {
158 break;
159 }
160
161 if (compareRankedSearchResults(candidate, current) < 0) {
162 high = middle;
163 } else {
164 low = middle + 1;
165 }
166 }
167
168 return low;
169}
170
171export function insertRankedSearchResult<T>(
172 rankedEntries: RankedSearchResult<T>[],
173 candidate: RankedSearchResult<T>,
174 limit: number,
175): void {
176 if (limit <= 0) {
177 return;
178 }
179
180 const insertionIndex = findInsertionIndex(rankedEntries, candidate);
181 if (rankedEntries.length < limit) {
182 rankedEntries.splice(insertionIndex, 0, candidate);
183 return;
184 }
185
186 if (insertionIndex >= limit) {
187 return;
188 }
189
190 rankedEntries.splice(insertionIndex, 0, candidate);
191 rankedEntries.pop();
192}