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 {
44 ServerProvider,
55 ServerProviderModel,
66 ServerProviderAuth,
7 ServerProviderSlashCommand,
78 ServerProviderState,
89} from "@t3tools/contracts";
910import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
1011import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1112import { 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
1418import {
1519 buildServerProvider,
......@@ -340,6 +344,74 @@ function claudeAuthMetadata(input: {
340344
341345const 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
343415/**
344416 * Probe account information by spawning a lightweight Claude Agent SDK
345417 * session and reading the initialization result.
......@@ -361,13 +433,16 @@ const probeClaudeCapabilities = (binaryPath: string) => {
361433 pathToClaudeCodeExecutable: binaryPath,
362434 abortController: abort,
363435 maxTurns: 0,
364 settingSources: [],
436 settingSources: ["user", "project", "local"],
365437 allowedTools: [],
366438 stderr: () => {},
367439 },
368440 });
369441 const init = await q.initializationResult();
370 return { subscriptionType: init.account?.subscriptionType };
442 return {
443 subscriptionType: init.account?.subscriptionType,
444 slashCommands: parseClaudeInitializationCommands(init.commands),
445 };
371446 }).pipe(
372447 Effect.ensuring(
373448 Effect.sync(() => {
......@@ -396,6 +471,9 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: Readonly
396471
397472export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
398473 resolveSubscriptionType?: (binaryPath: string) => Effect.Effect<string | undefined>,
474 resolveSlashCommands?: (
475 binaryPath: string,
476 ) => Effect.Effect<ReadonlyArray<ServerProviderSlashCommand> | undefined>,
399477): Effect.fn.Return<
400478 ServerProvider,
401479 ServerSettingsError,
......@@ -491,6 +569,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
491569 });
492570 }
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
494580 // ── Auth check + subscription detection ────────────────────────────
495581
496582 const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
......@@ -525,6 +611,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
525611 enabled: claudeSettings.enabled,
526612 checkedAt,
527613 models,
614 slashCommands: dedupedSlashCommands,
528615 probe: {
529616 installed: true,
530617 version: parsedVersion,
......@@ -544,6 +631,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
544631 enabled: claudeSettings.enabled,
545632 checkedAt,
546633 models,
634 slashCommands: dedupedSlashCommands,
547635 probe: {
548636 installed: true,
549637 version: parsedVersion,
......@@ -561,6 +649,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
561649 enabled: claudeSettings.enabled,
562650 checkedAt,
563651 models,
652 slashCommands: dedupedSlashCommands,
564653 probe: {
565654 installed: true,
566655 version: parsedVersion,
......@@ -583,12 +672,18 @@ export const ClaudeProviderLive = Layer.effect(
583672 const subscriptionProbeCache = yield* Cache.make({
584673 capacity: 1,
585674 timeToLive: Duration.minutes(5),
586 lookup: (binaryPath: string) =>
587 probeClaudeCapabilities(binaryPath).pipe(Effect.map((r) => r?.subscriptionType)),
675 lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath),
588676 });
589677
590 const checkProvider = checkClaudeProviderStatus((binaryPath) =>
591 Cache.get(subscriptionProbeCache, binaryPath),
678 const checkProvider = checkClaudeProviderStatus(
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 ),
592687 ).pipe(
593688 Effect.provideService(ServerSettingsService, serverSettings),
594689 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
apps/server/src/provider/Layers/CodexProvider.ts+38-5
......@@ -5,6 +5,7 @@ import type {
55 ServerProvider,
66 ServerProviderModel,
77 ServerProviderAuth,
8 ServerProviderSkill,
89 ServerProviderState,
910} from "@t3tools/contracts";
1011import {
......@@ -44,7 +45,7 @@ import {
4445 codexAuthSubType,
4546 type CodexAccountSnapshot,
4647} from "../codexAccount";
47import { probeCodexAccount } from "../codexAppServer";
48import { probeCodexDiscovery } from "../codexAppServer";
4849import { CodexProvider } from "../Services/CodexProvider";
4950import { ServerSettingsService } from "../../serverSettings";
5051import { ServerSettingsError } from "@t3tools/contracts";
......@@ -304,8 +305,9 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000;
304305const probeCodexCapabilities = (input: {
305306 readonly binaryPath: string;
306307 readonly homePath?: string;
308 readonly cwd: string;
307309}) =>
308 Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal })).pipe(
310 Effect.tryPromise((signal) => probeCodexDiscovery({ ...input, signal })).pipe(
309311 Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
310312 Effect.result,
311313 Effect.map((result) => {
......@@ -334,6 +336,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
334336 readonly binaryPath: string;
335337 readonly homePath?: string;
336338 }) => 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>,
337344): Effect.fn.Return<
338345 ServerProvider,
339346 ServerSettingsError,
......@@ -449,12 +456,22 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
449456 });
450457 }
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
452468 if (yield* hasCustomModelProvider) {
453469 return buildServerProvider({
454470 provider: PROVIDER,
455471 enabled: codexSettings.enabled,
456472 checkedAt,
457473 models,
474 skills,
458475 probe: {
459476 installed: true,
460477 version: parsedVersion,
......@@ -484,6 +501,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
484501 enabled: codexSettings.enabled,
485502 checkedAt,
486503 models: resolvedModels,
504 skills,
487505 probe: {
488506 installed: true,
489507 version: parsedVersion,
......@@ -500,6 +518,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
500518 enabled: codexSettings.enabled,
501519 checkedAt,
502520 models: resolvedModels,
521 skills,
503522 probe: {
504523 installed: true,
505524 version: parsedVersion,
......@@ -518,6 +537,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
518537 enabled: codexSettings.enabled,
519538 checkedAt,
520539 models: resolvedModels,
540 skills,
521541 probe: {
522542 installed: true,
523543 version: parsedVersion,
......@@ -543,16 +563,29 @@ export const CodexProviderLive = Layer.effect(
543563 capacity: 4,
544564 timeToLive: Duration.minutes(5),
545565 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];
547567 return probeCodexCapabilities({
548568 binaryPath,
569 cwd,
549570 ...(homePath ? { homePath } : {}),
550571 });
551572 },
552573 });
553574
554 const checkProvider = checkCodexProviderStatus((input) =>
555 Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])),
575 const getDiscovery = (input: {
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)),
556589 ).pipe(
557590 Effect.provideService(ServerSettingsService, serverSettings),
558591 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()))(
221221 ),
222222 );
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
224267 it.effect("hides spark from codex models for unsupported chatgpt plans", () =>
225268 Effect.gen(function* () {
226269 yield* withTempCodexHome();
......@@ -499,6 +542,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
499542 checkedAt: "2026-03-25T00:00:00.000Z",
500543 version: "1.0.0",
501544 models: [],
545 slashCommands: [],
546 skills: [],
502547 },
503548 {
504549 provider: "claudeAgent",
......@@ -509,6 +554,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
509554 checkedAt: "2026-03-25T00:00:00.000Z",
510555 version: "1.0.0",
511556 models: [],
557 slashCommands: [],
558 skills: [],
512559 },
513560 ] as const satisfies ReadonlyArray<ServerProvider>;
514561
......@@ -887,6 +934,85 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
887934 ),
888935 );
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
8901016 it.effect("returns an api key label for claude api key auth", () =>
8911017 Effect.gen(function* () {
8921018 const status = yield* checkClaudeProviderStatus();
apps/server/src/provider/codexAppServer.ts+95-8
......@@ -1,5 +1,6 @@
11import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
22import readline from "node:readline";
3import type { ServerProviderSkill } from "@t3tools/contracts";
34import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount";
45
56interface JsonRpcProbeResponse {
......@@ -10,10 +11,74 @@ interface JsonRpcProbeResponse {
1011 };
1112}
1213
14export interface CodexDiscoverySnapshot {
15 readonly account: CodexAccountSnapshot;
16 readonly skills: ReadonlyArray<ServerProviderSkill>;
17}
18
1319function readErrorMessage(response: JsonRpcProbeResponse): string | undefined {
1420 return typeof response.error?.message === "string" ? response.error.message : undefined;
1521}
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
1782export function buildCodexInitializeParams() {
1883 return {
1984 clientInfo: {
......@@ -40,11 +105,12 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo
40105 child.kill();
41106}
42107
43export async function probeCodexAccount(input: {
108export async function probeCodexDiscovery(input: {
44109 readonly binaryPath: string;
45110 readonly homePath?: string;
111 readonly cwd: string;
46112 readonly signal?: AbortSignal;
47}): Promise<CodexAccountSnapshot> {
113}): Promise<CodexDiscoverySnapshot> {
48114 return await new Promise((resolve, reject) => {
49115 const child = spawn(input.binaryPath, ["app-server"], {
50116 env: {
......@@ -57,6 +123,8 @@ export async function probeCodexAccount(input: {
57123 const output = readline.createInterface({ input: child.stdout });
58124
59125 let completed = false;
126 let account: CodexAccountSnapshot | undefined;
127 let skills: ReadonlyArray<ServerProviderSkill> | undefined;
60128
61129 const cleanup = () => {
62130 output.removeAllListeners();
......@@ -79,15 +147,25 @@ export async function probeCodexAccount(input: {
79147 reject(
80148 error instanceof Error
81149 ? error
82 : new Error(`Codex account probe failed: ${String(error)}.`),
150 : new Error(`Codex discovery probe failed: ${String(error)}.`),
83151 ),
84152 );
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
86162 if (input.signal?.aborted) {
87 fail(new Error("Codex account probe aborted."));
163 fail(new Error("Codex discovery probe aborted."));
88164 return;
89165 }
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
92170 const writeMessage = (message: unknown) => {
93171 if (!child.stdin.writable) {
......@@ -103,7 +181,7 @@ export async function probeCodexAccount(input: {
103181 try {
104182 parsed = JSON.parse(line);
105183 } 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."));
107185 return;
108186 }
109187
......@@ -120,18 +198,27 @@ export async function probeCodexAccount(input: {
120198 }
121199
122200 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: {} });
124203 return;
125204 }
126205
127206 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) {
128214 const errorMessage = readErrorMessage(response);
129215 if (errorMessage) {
130216 fail(new Error(`account/read failed: ${errorMessage}`));
131217 return;
132218 }
133219
134 finish(() => resolve(readCodexAccountSnapshot(response.result)));
220 account = readCodexAccountSnapshot(response.result);
221 maybeResolve();
135222 }
136223 });
137224
apps/server/src/provider/providerSnapshot.ts+6
......@@ -2,6 +2,8 @@ import type {
22 ModelCapabilities,
33 ServerProvider,
44 ServerProviderAuth,
5 ServerProviderSkill,
6 ServerProviderSlashCommand,
57 ServerProviderModel,
68 ServerProviderState,
79} from "@t3tools/contracts";
......@@ -130,6 +132,8 @@ export function buildServerProvider(input: {
130132 enabled: boolean;
131133 checkedAt: string;
132134 models: ReadonlyArray<ServerProviderModel>;
135 slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;
136 skills?: ReadonlyArray<ServerProviderSkill>;
133137 probe: ProviderProbeResult;
134138}): ServerProvider {
135139 return {
......@@ -142,6 +146,8 @@ export function buildServerProvider(input: {
142146 checkedAt: input.checkedAt,
143147 ...(input.probe.message ? { message: input.probe.message } : {}),
144148 models: input.models,
149 slashCommands: [...(input.slashCommands ?? [])],
150 skills: [...(input.skills ?? [])],
145151 };
146152}
147153
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+12
......@@ -129,6 +129,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
129129 }),
130130 );
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
132144 it.effect("tracks truncation without sorting every fuzzy match", () =>
133145 Effect.gen(function* () {
134146 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";
44import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";
55
66import { type ProjectEntry } from "@t3tools/contracts";
7import {
8 insertRankedSearchResult,
9 normalizeSearchQuery,
10 scoreQueryMatch,
11 type RankedSearchResult,
12} from "@t3tools/shared/searchRanking";
713
814import { GitCore } from "../../git/Services/GitCore.ts";
915import {
......@@ -40,10 +46,7 @@ interface SearchableWorkspaceEntry extends ProjectEntry {
4046 normalizedName: string;
4147}
4248
43interface RankedWorkspaceEntry {
44 entry: SearchableWorkspaceEntry;
45 score: number;
46}
49type RankedWorkspaceEntry = RankedSearchResult<SearchableWorkspaceEntry>;
4750
4851function toPosixPath(input: string): string {
4952 return input.replaceAll("\\", "/");
......@@ -74,45 +77,6 @@ function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEnt
7477 };
7578}
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
11680function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null {
11781 if (!query) {
11882 return entry.kind === "directory" ? 0 : 1;
......@@ -120,81 +84,32 @@ function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | nu
12084
12185 const { normalizedPath, normalizedName } = entry;
12286
123 if (normalizedName === query) return 0;
124 if (normalizedPath === query) return 1;
125 if (normalizedName.startsWith(query)) return 2;
126 if (normalizedPath.startsWith(query)) return 3;
127 if (normalizedPath.includes(`/${query}`)) return 4;
128 if (normalizedName.includes(query)) return 5;
129 if (normalizedPath.includes(query)) return 6;
130
131 const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query);
132 if (nameFuzzyScore !== null) {
133 return 100 + nameFuzzyScore;
134 }
135
136 const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query);
137 if (pathFuzzyScore !== null) {
138 return 200 + pathFuzzyScore;
87 const scores = [
88 scoreQueryMatch({
89 value: normalizedName,
90 query,
91 exactBase: 0,
92 prefixBase: 2,
93 includesBase: 5,
94 fuzzyBase: 100,
95 }),
96 scoreQueryMatch({
97 value: normalizedPath,
98 query,
99 exactBase: 1,
100 prefixBase: 3,
101 boundaryBase: 4,
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;
139110 }
140111
141 return null;
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();
112 return Math.min(...scores);
198113}
199114
200115function isPathInIgnoredDirectory(relativePath: string): boolean {
......@@ -469,7 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
469384 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
470385 return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe(
471386 Effect.map((index) => {
472 const normalizedQuery = normalizeQuery(input.query);
387 const normalizedQuery = normalizeSearchQuery(input.query, {
388 trimLeadingPattern: /^[@./]+/,
389 });
473390 const limit = Math.max(0, Math.floor(input.limit));
474391 const rankedEntries: RankedWorkspaceEntry[] = [];
475392 let matchedEntryCount = 0;
......@@ -481,11 +398,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
481398 }
482399
483400 matchedEntryCount += 1;
484 insertRankedEntry(rankedEntries, { entry, score }, limit);
401 insertRankedSearchResult(
402 rankedEntries,
403 { item: entry, score, tieBreaker: entry.path },
404 limit,
405 );
485406 }
486407
487408 return {
488 entries: rankedEntries.map((candidate) => candidate.entry),
409 entries: rankedEntries.map((candidate) => candidate.item),
489410 truncated: index.truncated || matchedEntryCount > limit,
490411 };
491412 }),
apps/web/src/components/ChatView.browser.tsx+53
......@@ -169,6 +169,8 @@ function createBaseServerConfig(): ServerConfig {
169169 auth: { status: "authenticated" },
170170 checkedAt: NOW_ISO,
171171 models: [],
172 slashCommands: [],
173 skills: [],
172174 },
173175 ],
174176 availableEditors: [],
......@@ -3885,4 +3887,55 @@ describe("ChatView timeline estimator parity (full app)", () => {
38853887 await mounted.cleanup();
38863888 }
38873889 });
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 });
38883941});
apps/web/src/components/ComposerPromptEditor.tsx+237-14
......@@ -5,6 +5,7 @@ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
55import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
66import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
77import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
8import { type ServerProviderSkill } from "@t3tools/contracts";
89import {
910 $applyNodeReplacement,
1011 $createRangeSelection,
......@@ -73,8 +74,11 @@ import {
7374 COMPOSER_INLINE_CHIP_CLASS_NAME,
7475 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7576 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
77 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
7678} from "./composerInlineChip";
7779import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
80import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
81import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
7882
7983const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
8084const SURROUND_SYMBOLS: [string, string][] = [
......@@ -102,6 +106,17 @@ type SerializedComposerMentionNode = Spread<
102106 SerializedTextNode
103107>;
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
105120type SerializedComposerTerminalContextNode = Spread<
106121 {
107122 context: TerminalContextDraft;
......@@ -189,6 +204,155 @@ function $createComposerMentionNode(path: string): ComposerMentionNode {
189204 return $applyNodeReplacement(new ComposerMentionNode(path));
190205}
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
192356function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) {
193357 return <ComposerPendingTerminalContextChip context={props.context} />;
194358}
......@@ -253,11 +417,16 @@ function $createComposerTerminalContextNode(
253417 return $applyNodeReplacement(new ComposerTerminalContextNode(context));
254418}
255419
256type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode;
420type ComposerInlineTokenNode =
421 | ComposerMentionNode
422 | ComposerSkillNode
423 | ComposerTerminalContextNode;
257424
258425function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode {
259426 return (
260 candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode
427 candidate instanceof ComposerMentionNode ||
428 candidate instanceof ComposerSkillNode ||
429 candidate instanceof ComposerTerminalContextNode
261430 );
262431}
263432
......@@ -302,6 +471,22 @@ function terminalContextSignature(contexts: ReadonlyArray<TerminalContextDraft>)
302471 .join("\u001e");
303472}
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
305490function clampExpandedCursor(value: string, cursor: number): number {
306491 if (!Number.isFinite(cursor)) return value.length;
307492 return Math.max(0, Math.min(value.length, Math.floor(cursor)));
......@@ -415,7 +600,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
415600 }
416601 return offset + Math.min(pointOffset, node.getTextContentSize());
417602 }
418 if (node instanceof ComposerTerminalContextNode) {
603 if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) {
419604 return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
420605 }
421606
......@@ -462,7 +647,7 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe
462647 }
463648 return offset + Math.min(pointOffset, node.getTextContentSize());
464649 }
465 if (node instanceof ComposerTerminalContextNode) {
650 if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) {
466651 return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
467652 }
468653
......@@ -488,7 +673,7 @@ function findSelectionPointAtOffset(
488673 node: LexicalNode,
489674 remainingRef: { value: number },
490675): { key: string; offset: number; type: "text" | "element" } | null {
491 if (node instanceof ComposerMentionNode) {
676 if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) {
492677 return findSelectionPointForInlineToken(node, remainingRef);
493678 }
494679 if (node instanceof ComposerTerminalContextNode) {
......@@ -657,6 +842,7 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
657842function $setComposerEditorPrompt(
658843 prompt: string,
659844 terminalContexts: ReadonlyArray<TerminalContextDraft>,
845 skillMetadata: ReadonlyMap<string, ComposerSkillMetadata>,
660846): void {
661847 const root = $getRoot();
662848 root.clear();
......@@ -669,6 +855,17 @@ function $setComposerEditorPrompt(
669855 paragraph.append($createComposerMentionNode(segment.path));
670856 continue;
671857 }
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 }
672869 if (segment.type === "terminal-context") {
673870 if (segment.context) {
674871 paragraph.append($createComposerTerminalContextNode(segment.context));
......@@ -705,6 +902,7 @@ interface ComposerPromptEditorProps {
705902 value: string;
706903 cursor: number;
707904 terminalContexts: ReadonlyArray<TerminalContextDraft>;
905 skills: ReadonlyArray<ServerProviderSkill>;
708906 disabled: boolean;
709907 placeholder: string;
710908 className?: string;
......@@ -946,9 +1144,11 @@ function ComposerInlineTokenBackspacePlugin() {
9461144
9471145function ComposerSurroundSelectionPlugin(props: {
9481146 terminalContexts: ReadonlyArray<TerminalContextDraft>;
1147 skills: ReadonlyArray<ServerProviderSkill>;
9491148}) {
9501149 const [editor] = useLexicalComposerContext();
9511150 const terminalContextsRef = useRef(props.terminalContexts);
1151 const skillMetadataRef = useRef(skillMetadataByName(props.skills));
9521152 const pendingSurroundSelectionRef = useRef<{
9531153 value: string;
9541154 expandedStart: number;
......@@ -964,6 +1164,10 @@ function ComposerSurroundSelectionPlugin(props: {
9641164 terminalContextsRef.current = props.terminalContexts;
9651165 }, [props.terminalContexts]);
9661166
1167 useEffect(() => {
1168 skillMetadataRef.current = skillMetadataByName(props.skills);
1169 }, [props.skills]);
1170
9671171 const applySurroundInsertion = useCallback(
9681172 (inputData: string): boolean => {
9691173 const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);
......@@ -1009,7 +1213,7 @@ function ComposerSurroundSelectionPlugin(props: {
10091213 selectionSnapshot.expandedEnd,
10101214 );
10111215 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);
10131217 const selectionStart = collapseExpandedComposerCursor(
10141218 nextValue,
10151219 selectionSnapshot.expandedStart,
......@@ -1211,6 +1415,7 @@ function ComposerPromptEditorInner({
12111415 value,
12121416 cursor,
12131417 terminalContexts,
1418 skills,
12141419 disabled,
12151420 placeholder,
12161421 className,
......@@ -1225,6 +1430,9 @@ function ComposerPromptEditorInner({
12251430 const initialCursor = clampCollapsedComposerCursor(value, cursor);
12261431 const terminalContextsSignature = terminalContextSignature(terminalContexts);
12271432 const terminalContextsSignatureRef = useRef(terminalContextsSignature);
1433 const skillsSignature = skillSignature(skills);
1434 const skillsSignatureRef = useRef(skillsSignature);
1435 const skillMetadataRef = useRef(skillMetadataByName(skills));
12281436 const snapshotRef = useRef({
12291437 value,
12301438 cursor: initialCursor,
......@@ -1241,6 +1449,10 @@ function ComposerPromptEditorInner({
12411449 onChangeRef.current = onChange;
12421450 }, [onChange]);
12431451
1452 useLayoutEffect(() => {
1453 skillMetadataRef.current = skillMetadataByName(skills);
1454 }, [skills]);
1455
12441456 useEffect(() => {
12451457 editor.setEditable(!disabled);
12461458 }, [disabled, editor]);
......@@ -1249,10 +1461,12 @@ function ComposerPromptEditorInner({
12491461 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
12501462 const previousSnapshot = snapshotRef.current;
12511463 const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature;
1464 const skillsChanged = skillsSignatureRef.current !== skillsSignature;
12521465 if (
12531466 previousSnapshot.value === value &&
12541467 previousSnapshot.cursor === normalizedCursor &&
1255 !contextsChanged
1468 !contextsChanged &&
1469 !skillsChanged
12561470 ) {
12571471 return;
12581472 }
......@@ -1264,18 +1478,20 @@ function ComposerPromptEditorInner({
12641478 terminalContextIds: terminalContexts.map((context) => context.id),
12651479 };
12661480 terminalContextsSignatureRef.current = terminalContextsSignature;
1481 skillsSignatureRef.current = skillsSignature;
12671482
12681483 const rootElement = editor.getRootElement();
12691484 const isFocused = Boolean(rootElement && document.activeElement === rootElement);
1270 if (previousSnapshot.value === value && !contextsChanged && !isFocused) {
1485 if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) {
12711486 return;
12721487 }
12731488
12741489 isApplyingControlledUpdateRef.current = true;
12751490 editor.update(() => {
1276 const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged;
1491 const shouldRewriteEditorState =
1492 previousSnapshot.value !== value || contextsChanged || skillsChanged;
12771493 if (shouldRewriteEditorState) {
1278 $setComposerEditorPrompt(value, terminalContexts);
1494 $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current);
12791495 }
12801496 if (shouldRewriteEditorState || isFocused) {
12811497 $setSelectionAtComposerOffset(normalizedCursor);
......@@ -1284,7 +1500,7 @@ function ComposerPromptEditorInner({
12841500 queueMicrotask(() => {
12851501 isApplyingControlledUpdateRef.current = false;
12861502 });
1287 }, [cursor, editor, terminalContexts, terminalContextsSignature, value]);
1503 }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]);
12881504
12891505 const focusAt = useCallback(
12901506 (nextCursor: number) => {
......@@ -1442,7 +1658,7 @@ function ComposerPromptEditorInner({
14421658 />
14431659 <OnChangePlugin onChange={handleEditorChange} />
14441660 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
1445 <ComposerSurroundSelectionPlugin terminalContexts={terminalContexts} />
1661 <ComposerSurroundSelectionPlugin terminalContexts={terminalContexts} skills={skills} />
14461662 <ComposerInlineTokenArrowPlugin />
14471663 <ComposerInlineTokenSelectionNormalizePlugin />
14481664 <ComposerInlineTokenBackspacePlugin />
......@@ -1460,6 +1676,7 @@ export const ComposerPromptEditor = forwardRef<
14601676 value,
14611677 cursor,
14621678 terminalContexts,
1679 skills,
14631680 disabled,
14641681 placeholder,
14651682 className,
......@@ -1472,13 +1689,18 @@ export const ComposerPromptEditor = forwardRef<
14721689) {
14731690 const initialValueRef = useRef(value);
14741691 const initialTerminalContextsRef = useRef(terminalContexts);
1692 const initialSkillMetadataRef = useRef(skillMetadataByName(skills));
14751693 const initialConfig = useMemo<InitialConfigType>(
14761694 () => ({
14771695 namespace: "t3tools-composer-editor",
14781696 editable: true,
1479 nodes: [ComposerMentionNode, ComposerTerminalContextNode],
1697 nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode],
14801698 editorState: () => {
1481 $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current);
1699 $setComposerEditorPrompt(
1700 initialValueRef.current,
1701 initialTerminalContextsRef.current,
1702 initialSkillMetadataRef.current,
1703 );
14821704 },
14831705 onError: (error) => {
14841706 throw error;
......@@ -1493,6 +1715,7 @@ export const ComposerPromptEditor = forwardRef<
14931715 value={value}
14941716 cursor={cursor}
14951717 terminalContexts={terminalContexts}
1718 skills={skills}
14961719 disabled={disabled}
14971720 placeholder={placeholder}
14981721 onRemoveTerminalContext={onRemoveTerminalContext}
apps/web/src/components/KeybindingsToast.browser.tsx+2
......@@ -80,6 +80,8 @@ function createBaseServerConfig(): ServerConfig {
8080 auth: { status: "authenticated" },
8181 checkedAt: NOW_ISO,
8282 models: [],
83 slashCommands: [],
84 skills: [],
8385 },
8486 ],
8587 availableEditors: [],
apps/web/src/components/chat/ChatComposer.tsx+141-22
......@@ -67,6 +67,8 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
6767import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
6868import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
6969import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
70import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
71import { searchSlashCommandItems } from "./composerSlashCommandSearch";
7072import {
7173 getComposerProviderState,
7274 renderProviderTraitsMenuContent,
......@@ -98,6 +100,8 @@ import type { SessionPhase, Thread } from "../../types";
98100import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
99101import type { PendingApproval, PendingUserInput } from "../../session-logic";
100102import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
103import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
104import { searchProviderSkills } from "../../providerSkillSearch";
101105
102106const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
103107
......@@ -558,6 +562,10 @@ export const ChatComposer = memo(
558562 });
559563
560564 const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider);
565 const selectedProviderStatus = useMemo(
566 () => providerStatuses.find((provider) => provider.provider === selectedProvider),
567 [providerStatuses, selectedProvider],
568 );
561569
562570 const composerProviderState = useMemo(
563571 () =>
......@@ -634,6 +642,9 @@ export const ChatComposer = memo(
634642 detectComposerTrigger(prompt, prompt.length),
635643 );
636644 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
645 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
646 null,
647 );
637648 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
638649 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
639650 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
......@@ -699,7 +710,7 @@ export const ChatComposer = memo(
699710 }));
700711 }
701712 if (composerTrigger.kind === "slash-command") {
702 const slashCommandItems = [
713 const builtInSlashCommandItems = [
703714 {
704715 id: "slash:model",
705716 type: "slash-command",
......@@ -722,13 +733,38 @@ export const ChatComposer = memo(
722733 description: "Switch this thread back to normal build mode",
723734 },
724735 ] 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 );
725746 const query = composerTrigger.query.trim().toLowerCase();
747 const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems];
726748 if (!query) {
727 return [...slashCommandItems];
749 return slashCommandItems;
728750 }
729 return slashCommandItems.filter(
730 (item) => item.command.includes(query) || item.label.slice(1).includes(query),
731 );
751 return searchSlashCommandItems(slashCommandItems, query);
752 }
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 }));
732768 }
733769 return searchableModelOptions
734770 .filter(({ searchSlug, searchName, searchProvider }) => {
......@@ -748,16 +784,32 @@ export const ChatComposer = memo(
748784 label: name,
749785 description: `${providerLabel} · ${slug}`,
750786 }));
751 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
787 }, [
788 composerTrigger,
789 searchableModelOptions,
790 selectedProvider,
791 selectedProviderStatus,
792 workspaceEntries,
793 ]);
752794
753795 const composerMenuOpen = Boolean(composerTrigger);
754 const activeComposerMenuItem = useMemo(
755 () =>
756 composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
757 composerMenuItems[0] ??
758 null,
759 [composerHighlightedItemId, composerMenuItems],
760 );
796 const composerMenuSearchKey = composerTrigger
797 ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
798 : null;
799 const activeComposerMenuItem = useMemo(() => {
800 const activeItemId = resolveComposerMenuActiveItemId({
801 items: composerMenuItems,
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
762814 composerMenuOpenRef.current = composerMenuOpen;
763815 composerMenuItemsRef.current = composerMenuItems;
......@@ -805,14 +857,21 @@ export const ChatComposer = memo(
805857 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
806858 workspaceEntriesQuery.isLoading ||
807859 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
809869 // ------------------------------------------------------------------
810870 // Provider traits UI
811871 // ------------------------------------------------------------------
812872 const setPromptFromTraits = useCallback(
813873 (nextPrompt: string) => {
814 const currentPrompt = promptRef.current;
815 if (nextPrompt === currentPrompt) {
874 if (nextPrompt === promptRef.current) {
816875 scheduleComposerFocus();
817876 return;
818877 }
......@@ -936,14 +995,28 @@ export const ChatComposer = memo(
936995 useEffect(() => {
937996 if (!composerMenuOpen) {
938997 setComposerHighlightedItemId(null);
998 setComposerHighlightedSearchKey(null);
939999 return;
9401000 }
1001 const nextActiveItemId = resolveComposerMenuActiveItemId({
1002 items: composerMenuItems,
1003 highlightedItemId: composerHighlightedItemId,
1004 currentSearchKey: composerMenuSearchKey,
1005 highlightedSearchKey: composerHighlightedSearchKey,
1006 });
9411007 setComposerHighlightedItemId((existing) =>
942 existing && composerMenuItems.some((item) => item.id === existing)
943 ? existing
944 : (composerMenuItems[0]?.id ?? null),
1008 existing === nextActiveItemId ? existing : nextActiveItemId,
9451009 );
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
9481021 const lastSyncedPendingInputRef = useRef<{
9491022 requestId: string | null;
......@@ -1314,6 +1387,42 @@ export const ChatComposer = memo(
13141387 }
13151388 return;
13161389 }
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 }
13171426 onProviderModelSelect(item.provider, item.model);
13181427 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
13191428 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
......@@ -1330,9 +1439,13 @@ export const ChatComposer = memo(
13301439 ],
13311440 );
13321441
1333 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
1334 setComposerHighlightedItemId(itemId);
1335 }, []);
1442 const onComposerMenuItemHighlighted = useCallback(
1443 (itemId: string | null) => {
1444 setComposerHighlightedItemId(itemId);
1445 setComposerHighlightedSearchKey(composerMenuSearchKey);
1446 },
1447 [composerMenuSearchKey],
1448 );
13361449
13371450 const nudgeComposerMenuHighlight = useCallback(
13381451 (key: "ArrowDown" | "ArrowUp") => {
......@@ -1657,6 +1770,11 @@ export const ChatComposer = memo(
16571770 resolvedTheme={resolvedTheme}
16581771 isLoading={isComposerMenuLoading}
16591772 triggerKind={composerTriggerKind}
1773 groupSlashCommandSections={
1774 composerTrigger?.kind === "slash-command" &&
1775 composerTrigger.query.trim().length === 0
1776 }
1777 emptyStateText={composerMenuEmptyState}
16601778 activeItemId={activeComposerMenuItem?.id ?? null}
16611779 onHighlightedItemChange={onComposerMenuItemHighlighted}
16621780 onSelect={onSelectComposerItem}
......@@ -1746,6 +1864,7 @@ export const ChatComposer = memo(
17461864 ? composerTerminalContexts
17471865 : []
17481866 }
1867 skills={selectedProviderStatus?.skills ?? []}
17491868 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
17501869 onChange={onPromptChange}
17511870 onCommandKeyDown={onComposerCommandKey}
apps/web/src/components/chat/ComposerCommandMenu.tsx+161-27
......@@ -1,10 +1,24 @@
1import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts";
2import { memo, useLayoutEffect, useRef } from "react";
3import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
1import {
2 type ProjectEntry,
3 type ProviderKind,
4 type ServerProviderSkill,
5 type ServerProviderSlashCommand,
6} from "@t3tools/contracts";
47import { BotIcon } from "lucide-react";
8import { memo, useLayoutEffect, useMemo, useRef } from "react";
9
10import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
11import { formatProviderSkillInstallSource } from "~/providerSkillPresentation";
512import { cn } from "~/lib/utils";
613import { 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";
822import { VscodeEntryIcon } from "./VscodeEntryIcon";
923
1024export type ComposerCommandItem =
......@@ -23,6 +37,14 @@ export type ComposerCommandItem =
2337 label: string;
2438 description: string;
2539 }
40 | {
41 id: string;
42 type: "provider-slash-command";
43 provider: ProviderKind;
44 command: ServerProviderSlashCommand;
45 label: string;
46 description: string;
47 }
2648 | {
2749 id: string;
2850 type: "model";
......@@ -30,18 +52,83 @@ export type ComposerCommandItem =
3052 model: string;
3153 label: string;
3254 description: string;
55 }
56 | {
57 id: string;
58 type: "skill";
59 provider: ProviderKind;
60 skill: ServerProviderSkill;
61 label: string;
62 description: string;
3363 };
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
35115export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
36116 items: ComposerCommandItem[];
37117 resolvedTheme: "light" | "dark";
38118 isLoading: boolean;
39119 triggerKind: ComposerTriggerKind | null;
120 groupSlashCommandSections?: boolean;
121 emptyStateText?: string;
40122 activeItemId: string | null;
41123 onHighlightedItemChange: (itemId: string | null) => void;
42124 onSelect: (item: ComposerCommandItem) => void;
43125}) {
44126 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
46133 useLayoutEffect(() => {
47134 if (!props.activeItemId || !listRef.current) return;
......@@ -65,27 +152,56 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
65152 ref={listRef}
66153 className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"
67154 >
68 <CommandList className="max-h-64">
69 {props.items.map((item) => (
70 <ComposerCommandMenuItem
71 key={item.id}
72 item={item}
73 resolvedTheme={props.resolvedTheme}
74 isActive={props.activeItemId === item.id}
75 onHighlight={props.onHighlightedItemChange}
76 onSelect={props.onSelect}
77 />
155 <CommandList className="max-h-72">
156 {groups.map((group, groupIndex) => (
157 <div key={group.id}>
158 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}
159 <CommandGroup>
160 {group.label ? (
161 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
162 {group.label}
163 </CommandGroupLabel>
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>
78177 ))}
79178 </CommandList>
80 {props.items.length === 0 && (
81 <p className="px-3 py-2 text-muted-foreground/70 text-xs">
82 {props.isLoading
83 ? "Searching workspace files..."
84 : props.triggerKind === "path"
85 ? "No matching files or folders."
86 : "No matching command."}
87 </p>
88 )}
179 {props.items.length === 0 ? (
180 <div className="px-3 py-2">
181 {props.triggerKind === "skill" ? (
182 <CommandGroup>
183 <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
184 Skills
185 </CommandGroupLabel>
186 <p className="text-muted-foreground/70 text-xs">
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}
89205 </div>
90206 </Command>
91207 );
......@@ -98,6 +214,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
98214 onHighlight: (itemId: string | null) => void;
99215 onSelect: (item: ComposerCommandItem) => void;
100216}) {
217 const skillSourceLabel =
218 props.item.type === "skill" ? formatProviderSkillInstallSource(props.item.skill) : null;
219
101220 return (
102221 <CommandItem
103222 value={props.item.id}
......@@ -124,17 +243,32 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
124243 />
125244 ) : null}
126245 {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>
128257 ) : null}
129258 {props.item.type === "model" ? (
130259 <Badge variant="outline" className="px-1.5 py-0 text-[10px]">
131260 model
132261 </Badge>
133262 ) : null}
134 <span className="flex min-w-0 items-center gap-1.5 truncate">
135 <span className="truncate">{props.item.label}</span>
263 <span className="flex min-w-0 flex-1 items-center gap-2">
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>
136268 </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}
138272 </CommandItem>
139273 );
140274});
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+6
......@@ -24,6 +24,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
2424 status: "ready",
2525 auth: { status: "authenticated" },
2626 checkedAt: new Date().toISOString(),
27 slashCommands: [],
28 skills: [],
2729 models: [
2830 {
2931 slug: "gpt-5-codex",
......@@ -59,6 +61,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
5961 status: "ready",
6062 auth: { status: "authenticated" },
6163 checkedAt: new Date().toISOString(),
64 slashCommands: [],
65 skills: [],
6266 models: [
6367 {
6468 slug: "claude-opus-4-6",
......@@ -120,6 +124,8 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider {
120124 auth: { status: "authenticated" },
121125 checkedAt: new Date().toISOString(),
122126 models,
127 slashCommands: [],
128 skills: [],
123129 };
124130}
125131
apps/web/src/components/chat/TraitsPicker.browser.tsx+4
......@@ -44,6 +44,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
4444 status: "ready",
4545 auth: { status: "authenticated" },
4646 checkedAt: "2026-01-01T00:00:00.000Z",
47 slashCommands: [],
48 skills: [],
4749 models: [
4850 {
4951 slug: "gpt-5.4",
......@@ -70,6 +72,8 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
7072 status: "ready",
7173 auth: { status: "authenticated" },
7274 checkedAt: "2026-01-01T00:00:00.000Z",
75 slashCommands: [],
76 skills: [],
7377 models: [
7478 {
7579 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
55
66export 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
811export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =
912 "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", () => {
2929 ]);
3030 });
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
3246 it("keeps inline terminal context placeholders at their prompt positions", () => {
3347 expect(
3448 splitPromptIntoComposerSegments(
......@@ -53,6 +67,21 @@ describe("splitPromptIntoComposerSegments", () => {
5367 { type: "text", text: "tail" },
5468 ]);
5569 });
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 });
5685});
5786
5887describe("selectionTouchesMentionBoundary", () => {
apps/web/src/composer-editor-mentions.ts+60-13
......@@ -12,12 +12,17 @@ export type ComposerPromptSegment =
1212 type: "mention";
1313 path: string;
1414 }
15 | {
16 type: "skill";
17 name: string;
18 }
1519 | {
1620 type: "terminal-context";
1721 context: TerminalContextDraft | null;
1822 };
1923
2024const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
25const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
2126
2227function rangeIncludesIndex(start: number, end: number, index: number): boolean {
2328 return start <= index && index < end;
......@@ -33,6 +38,50 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void
3338 segments.push({ type: "text", text });
3439}
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
3685function forEachPromptSegmentSlice(
3786 prompt: string,
3887 visitor: (
......@@ -117,26 +166,24 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
117166 return segments;
118167 }
119168
169 const tokenMatches = collectInlineTokenMatches(text);
120170 let cursor = 0;
121 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
122 const fullMatch = match[0];
123 const prefix = match[1] ?? "";
124 const path = match[2] ?? "";
125 const matchIndex = match.index ?? 0;
126 const mentionStart = matchIndex + prefix.length;
127 const mentionEnd = mentionStart + fullMatch.length - prefix.length;
171 for (const match of tokenMatches) {
172 if (match.start < cursor) {
173 continue;
174 }
128175
129 if (mentionStart > cursor) {
130 pushTextSegment(segments, text.slice(cursor, mentionStart));
176 if (match.start > cursor) {
177 pushTextSegment(segments, text.slice(cursor, match.start));
131178 }
132179
133 if (path.length > 0) {
134 segments.push({ type: "mention", path });
180 if (match.type === "mention") {
181 segments.push({ type: "mention", path: match.value });
135182 } else {
136 pushTextSegment(segments, text.slice(mentionStart, mentionEnd));
183 segments.push({ type: "skill", name: match.value });
137184 }
138185
139 cursor = mentionEnd;
186 cursor = match.end;
140187 }
141188
142189 if (cursor < text.length) {
apps/web/src/composer-logic.test.ts+53
......@@ -60,6 +60,30 @@ describe("detectComposerTrigger", () => {
6060 });
6161 });
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
6387 it("detects @path trigger in the middle of existing text", () => {
6488 // User typed @ between "inspect " and "in this sentence"
6589 const text = "Please inspect @in this sentence";
......@@ -133,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => {
133157
134158 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
135159 });
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 });
136170});
137171
138172describe("collapseExpandedComposerCursor", () => {
......@@ -158,6 +192,16 @@ describe("collapseExpandedComposerCursor", () => {
158192 expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2);
159193 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
160194 });
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 });
161205});
162206
163207describe("clampCollapsedComposerCursor", () => {
......@@ -233,6 +277,15 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => {
233277 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true);
234278 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true);
235279 });
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 });
236289});
237290
238291describe("parseStandaloneComposerSlashCommand", () => {
apps/web/src/composer-logic.ts+50-14
......@@ -1,7 +1,7 @@
11import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
22import { 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";
55export type ComposerSlashCommand = "model" | "plan" | "default";
66
77export interface ComposerTrigger {
......@@ -11,9 +11,12 @@ export interface ComposerTrigger {
1111 rangeEnd: number;
1212}
1313
14const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
1514const 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" },
1720): boolean => segment.type !== "text";
1821
1922function clampCursor(text: string, cursor: number): number {
......@@ -59,6 +62,15 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
5962 expandedCursor += expandedLength;
6063 continue;
6164 }
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 }
6274 if (segment.type === "terminal-context") {
6375 if (remaining <= 1) {
6476 return expandedCursor + remaining;
......@@ -80,7 +92,11 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
8092}
8193
8294function 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" },
84100): number {
85101 if (segment.type === "text") {
86102 return segment.text.length;
......@@ -90,7 +106,10 @@ function collapsedSegmentLength(
90106
91107function clampCollapsedComposerCursorForSegments(
92108 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" }
94113 >,
95114 cursorInput: number,
96115): number {
......@@ -134,6 +153,18 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
134153 collapsedCursor += 1;
135154 continue;
136155 }
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 }
137168 if (segment.type === "terminal-context") {
138169 if (remaining <= 1) {
139170 return collapsedCursor + remaining;
......@@ -201,15 +232,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
201232 rangeEnd: cursor,
202233 };
203234 }
204 if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) {
205 return {
206 kind: "slash-command",
207 query: commandQuery,
208 rangeStart: lineStart,
209 rangeEnd: cursor,
210 };
211 }
212 return null;
235 return {
236 kind: "slash-command",
237 query: commandQuery,
238 rangeStart: lineStart,
239 rangeEnd: cursor,
240 };
213241 }
214242
215243 const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix);
......@@ -225,6 +253,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
225253
226254 const tokenStart = tokenStartForCursor(text, cursor);
227255 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 }
228264 if (!token.startsWith("@")) {
229265 return null;
230266 }
apps/web/src/localApi.test.ts+2
......@@ -210,6 +210,8 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [
210210 auth: { status: "authenticated" },
211211 checkedAt: "2026-01-01T00:00:00.000Z",
212212 models: [],
213 slashCommands: [],
214 skills: [],
213215 },
214216];
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> = [
4848 auth: { status: "authenticated" },
4949 checkedAt: "2026-01-01T00:00:00.000Z",
5050 models: [],
51 slashCommands: [],
52 skills: [],
5153 },
5254];
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 @@
1import { Schema } from "effect";
1import { Effect, Schema } from "effect";
22import { ExecutionEnvironmentDescriptor } from "./environment";
33import { ServerAuthDescriptor } from "./auth";
44import {
......@@ -58,6 +58,29 @@ export const ServerProviderModel = Schema.Struct({
5858});
5959export 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
6184export const ServerProvider = Schema.Struct({
6285 provider: ProviderKind,
6386 enabled: Schema.Boolean,
......@@ -68,6 +91,10 @@ export const ServerProvider = Schema.Struct({
6891 checkedAt: IsoDateTime,
6992 message: Schema.optional(TrimmedNonEmptyString),
7093 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([]))),
7198});
7299export type ServerProvider = typeof ServerProvider.Type;
73100
packages/shared/package.json+4
......@@ -52,6 +52,10 @@
5252 "types": "./src/projectScripts.ts",
5353 "import": "./src/projectScripts.ts"
5454 },
55 "./searchRanking": {
56 "types": "./src/searchRanking.ts",
57 "import": "./src/searchRanking.ts"
58 },
5559 "./qrCode": {
5660 "types": "./src/qrCode.ts",
5761 "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}