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