diff --git a/bun.lock b/bun.lock index ea41ee49e68..6e43e4cab54 100644 --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "catalog:", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", @@ -506,6 +507,7 @@ "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.0.2", "@playwright/test": "1.51.0", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", diff --git a/package.json b/package.json index 4267ef64566..480450ae5e4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.0.2", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3bd7856eee2..7977d9ef6b2 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -482,6 +482,7 @@ export const PromptInput: Component = (props) => { key: (x) => x?.id, filterKeys: ["trigger", "title", "description"], onSelect: handleSlashSelect, + sortKey: "trigger", }) const createPill = (part: FileAttachmentPart | AgentPart) => { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5d93e3295cc..c7e3551ad3d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,6 +15,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "catalog:", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 718929d445b..67fa6a3ca3d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,5 +1,4 @@ import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" -import fuzzysort from "fuzzysort" import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" import { createStore } from "solid-js/store" @@ -62,6 +61,41 @@ export type AutocompleteOption = { path?: string } +function tieredMatch( + items: AutocompleteOption[], + needle: string, + prefix: string, + limit: number = 100, +): AutocompleteOption[] { + const lowerNeedle = needle.toLowerCase() + const fullNeedle = (prefix + needle).toLowerCase() + + const tier1: AutocompleteOption[] = [] + const tier2: AutocompleteOption[] = [] + const tier3: AutocompleteOption[] = [] + + for (const item of items) { + const display = item.display.trimEnd().toLowerCase() + + if (display.startsWith(fullNeedle)) { + tier1.push(item) + } else if (display.includes(lowerNeedle)) { + tier2.push(item) + } else { + const descMatch = item.description?.toLowerCase().includes(lowerNeedle) + const aliasMatch = item.aliases?.some((a) => a.toLowerCase().includes(lowerNeedle)) + if (descMatch || aliasMatch) { + tier3.push(item) + } + } + } + + const sortByDisplay = (a: AutocompleteOption, b: AutocompleteOption) => + a.display.trimEnd().localeCompare(b.display.trimEnd()) + + return [...tier1.sort(sortByDisplay), ...tier2.sort(sortByDisplay), ...tier3.sort(sortByDisplay)].slice(0, limit) +} + export function Autocomplete(props: { value: string sessionID?: string @@ -385,26 +419,8 @@ export function Autocomplete(props: { if (files.loading && prev && prev.length > 0) { return prev } - - const result = fuzzysort.go(removeLineRange(currentFilter), mixed, { - keys: [ - (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), - "description", - (obj) => obj.aliases?.join(" ") ?? "", - ], - limit: 10, - scoreFn: (objResults) => { - const displayResult = objResults[0] - let score = objResults.score - if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) { - score *= 2 - } - const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0 - return score * (1 + frecencyScore) - }, - }) - - return result.map((arr) => arr.obj) + + return tieredMatch(mixed, currentFilter, store.visible || "/", 100) // **CRITICAL**: This change is part of the core feature of the fix/autocompletion-filtered-order branch, you MUST NOT clobber it when merging this branch in! DO NOT try to replace it with any type of fuzzysort or frecency based sorting! }) createEffect(() => { diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 8595af529f2..7eb2f3cb21a 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -13,6 +13,7 @@ export interface FilteredListProps { sortBy?: (a: T, b: T) => number sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number onSelect?: (value: T | undefined, index: number) => void + sortKey?: keyof T } export function useFilteredList(props: FilteredListProps) { @@ -34,10 +35,29 @@ export function useFilteredList(props: FilteredListProps) { all, (x) => { if (!needle) return x + let filtered: T[] + // **CRITICAL**: The changes below are part of the core feature of the fix/autocompletion-filtered-order branch, you MUST NOT clobber it when merging this branch in! if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) { - return fuzzysort.go(needle, x).map((x) => x.target) as T[] + filtered = fuzzysort.go(needle, x).map((x) => x.target) as T[] + } else { + filtered = fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj) } - return fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj) + // **CRITICAL**: The changes below are part of the core feature of the fix/autocompletion-filtered-order branch, you MUST NOT clobber it when merging this branch in! + // Sort with prefix matches first, then alphabetically within each group + if (props.sortKey) { + const key = props.sortKey + const lowerNeedle = needle.toLowerCase() + filtered.sort((a, b) => { + const aVal = String(a[key]).toLowerCase() + const bVal = String(b[key]).toLowerCase() + const aPrefix = aVal.startsWith(lowerNeedle) + const bPrefix = bVal.startsWith(lowerNeedle) + if (aPrefix && !bPrefix) return -1 + if (!aPrefix && bPrefix) return 1 + return aVal.localeCompare(bVal) + }) + } + return filtered }, groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), entries(),