Compare commits

...

1 Commits

Author SHA1 Message Date
Shoubhit Dash 34b9eb4caa fix(tui): match slash command typos
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-08-17 19:16:27 +00:00
4 changed files with 103 additions and 2 deletions
@@ -32,6 +32,7 @@ import {
directoryAutocompleteSearch,
slashArgumentAutocomplete,
} from "../../prompt/directory-completion"
import { slashCommandMatches } from "../../prompt/slash-command-match"
export type AutocompleteRef = {
onInput: (value: string) => void
@@ -624,7 +625,17 @@ export function Autocomplete(props: {
})
.map((arr) => arr.obj)
return [...fuzziedNonFiles, ...fileOptions].slice(0, 10)
const matchedNonFiles =
store.visible === "command"
? slashCommandMatches({
query: searchValue,
options: nonFileOptions,
matches: fuzziedNonFiles,
names: (item) => [item.display.trimEnd(), ...(item.aliases ?? [])],
})
: fuzziedNonFiles
return [...matchedNonFiles, ...fileOptions].slice(0, 10)
})
createEffect(() => {
+9 -1
View File
@@ -9,6 +9,7 @@ import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable
import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "../prompt/content"
import fuzzysort from "fuzzysort"
import { slashCommandMatches } from "../prompt/slash-command-match"
import path from "path"
import { pathToFileURL } from "node:url"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -469,7 +470,7 @@ export function createPromptState(input: PromptInput): PromptState {
]
}
return fuzzysort
const matches = fuzzysort
.go(next, mixed, {
keys: [
(item) => (item.kind === "mention" ? item.value : item.kind === "skill" ? item.id : item.name).trimEnd(),
@@ -478,6 +479,13 @@ export function createPromptState(input: PromptInput): PromptState {
],
})
.map((item) => item.obj)
return slashCommandMatches({
query: next,
options: mixed,
matches,
names: (item) => [item.kind === "mention" ? item.value : item.kind === "skill" ? item.id : item.name],
})
})
const menu = createFooterMenuState({ count: () => options().length, limit: AUTOCOMPLETE_ROWS })
const popup = createMemo(() => {
@@ -0,0 +1,53 @@
export function slashCommandMatches<T>(input: {
query: string
options: readonly T[]
matches: readonly T[]
names: (option: T) => readonly string[]
}) {
const query = normalize(input.query)
if (query.length < 3) return [...input.matches]
if (input.options.some((option) => input.names(option).some((name) => normalize(name).startsWith(query)))) {
return [...input.matches]
}
const typos = input.options
.map((option, index) => ({
option,
index,
distance: Math.min(...input.names(option).map((name) => damerauLevenshtein(query, normalize(name)))),
}))
.filter((item) => item.distance <= 1)
.sort((a, b) => a.distance - b.distance || a.index - b.index)
.map((item) => item.option)
if (typos.length === 0) return [...input.matches]
const added = new Set(typos)
return [...typos, ...input.matches.filter((option) => !added.has(option))]
}
function normalize(value: string) {
return value.trim().replace(/^\//, "").toLowerCase()
}
function damerauLevenshtein(source: string, target: string) {
const rows = Array.from({ length: source.length + 1 }, (_, row) =>
Array.from({ length: target.length + 1 }, (_, column) => (row === 0 ? column : column === 0 ? row : 0)),
)
for (let row = 1; row <= source.length; row++) {
for (let column = 1; column <= target.length; column++) {
const substitution = source[row - 1] === target[column - 1] ? 0 : 1
rows[row][column] = Math.min(
rows[row - 1][column] + 1,
rows[row][column - 1] + 1,
rows[row - 1][column - 1] + substitution,
)
if (row > 1 && column > 1 && source[row - 1] === target[column - 2] && source[row - 2] === target[column - 1]) {
rows[row][column] = Math.min(rows[row][column], rows[row - 2][column - 2] + 1)
}
}
}
return rows[source.length][target.length]
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { slashCommandMatches } from "../../src/prompt/slash-command-match"
const options = ["/redo", "/undo", "/unshare"]
const names = (option: string) => [option]
describe("slashCommandMatches", () => {
test("preserves existing fuzzy results", () => {
expect(
slashCommandMatches({
query: "/un",
options,
matches: ["/unshare", "/undo", "/redo"],
names,
}),
).toEqual(["/unshare", "/undo", "/redo"])
})
test("falls back for one-character command typos", () => {
expect(slashCommandMatches({ query: "/udno", options, matches: ["/redo"], names })).toEqual(["/undo", "/redo"])
expect(slashCommandMatches({ query: "/rdo", options, matches: [], names })).toEqual(["/redo"])
expect(slashCommandMatches({ query: "/undos", options, matches: [], names })).toEqual(["/undo"])
})
test("does not surface distant or short matches", () => {
expect(slashCommandMatches({ query: "/udnno", options, matches: [], names })).toEqual([])
expect(slashCommandMatches({ query: "/rd", options, matches: ["/redo"], names })).toEqual(["/redo"])
})
})