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