diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index a3381a6a2..5b82b1f3e 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -11,6 +11,7 @@ import { useAutocomplete } from "./use-autocomplete"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands"; +import { filterAndRankCommandAutocompleteEntries } from "@/utils/agent-command-autocomplete"; import { applyFileMentionReplacement, findActiveFileMention, @@ -314,7 +315,6 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut } if (mode === "command") { - const filterLower = commandFilterQuery.toLowerCase(); const providerCommands = commands.map( (command): AvailableCommand => ({ source: "provider", command }), ); @@ -326,13 +326,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut ), ...providerCommands, ]; - const matches = availableCommands.filter((entry) => { - if (entry.source === "provider") { - return entry.command.name.toLowerCase().includes(filterLower); - } - const candidates = [entry.command.name, ...entry.command.aliases]; - return candidates.some((candidate) => candidate.toLowerCase().includes(filterLower)); - }); + const matches = filterAndRankCommandAutocompleteEntries( + availableCommands, + commandFilterQuery, + ); const orderedMatches = orderAutocompleteOptions(matches); return orderedMatches.map(mapCommandToOption); } diff --git a/packages/app/src/utils/agent-command-autocomplete.test.ts b/packages/app/src/utils/agent-command-autocomplete.test.ts new file mode 100644 index 000000000..53c01efd2 --- /dev/null +++ b/packages/app/src/utils/agent-command-autocomplete.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { filterAndRankCommandAutocompleteEntries } from "./agent-command-autocomplete"; + +describe("filterAndRankCommandAutocompleteEntries", () => { + const entries = [ + { source: "provider" as const, command: { name: "paseo-committee" } }, + { source: "provider" as const, command: { name: "commit" } }, + { source: "provider" as const, command: { name: "paseo-advisor" } }, + ]; + + it("ranks command-name prefixes above later word-boundary partial matches", () => { + const result = filterAndRankCommandAutocompleteEntries(entries, "comm"); + + expect(result.map((entry) => entry.command.name)).toEqual(["commit", "paseo-committee"]); + }); + + it("matches client command aliases", () => { + const result = filterAndRankCommandAutocompleteEntries( + [ + { source: "client" as const, command: { name: "exit", aliases: ["quit", "q"] } }, + { source: "client" as const, command: { name: "clear", aliases: ["new"] } }, + ], + "q", + ); + + expect(result.map((entry) => entry.command.name)).toEqual(["exit"]); + }); +}); diff --git a/packages/app/src/utils/agent-command-autocomplete.ts b/packages/app/src/utils/agent-command-autocomplete.ts new file mode 100644 index 000000000..8beb0c07b --- /dev/null +++ b/packages/app/src/utils/agent-command-autocomplete.ts @@ -0,0 +1,48 @@ +import { compareMatchScores, type MatchScore, scoreTextFields } from "@/utils/score-match"; + +interface CommandAutocompleteEntry { + command: { + name: string; + aliases?: readonly string[]; + }; +} + +interface ScoredCommandAutocompleteEntry { + entry: TEntry; + score: MatchScore; +} + +function scoreCommandAutocompleteEntry( + entry: CommandAutocompleteEntry, + query: string, +): MatchScore | null { + return scoreTextFields(query, [entry.command.name, ...(entry.command.aliases ?? [])]); +} + +export function filterAndRankCommandAutocompleteEntries( + entries: readonly TEntry[], + query: string, +): TEntry[] { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) { + return [...entries]; + } + + const scoredEntries: ScoredCommandAutocompleteEntry[] = []; + for (const entry of entries) { + const score = scoreCommandAutocompleteEntry(entry, normalizedQuery); + if (score) { + scoredEntries.push({ entry, score }); + } + } + + scoredEntries.sort((a, b) => { + const scoreComparison = compareMatchScores(a.score, b.score); + if (scoreComparison !== 0) { + return scoreComparison; + } + return a.entry.command.name.localeCompare(b.entry.command.name); + }); + + return scoredEntries.map((scored) => scored.entry); +}