diff --git a/src/cli/ui/slash/dispatch.ts b/src/cli/ui/slash/dispatch.ts index 8b51c8d..67c9988 100644 --- a/src/cli/ui/slash/dispatch.ts +++ b/src/cli/ui/slash/dispatch.ts @@ -17,6 +17,7 @@ import { handlers as sessionsHandlers } from "./handlers/sessions.js"; import { handlers as skillHandlers } from "./handlers/skill.js"; import { handlers as themeHandlers } from "./handlers/theme.js"; import { handlers as webSearchEngineHandlers } from "./handlers/web-search-engine.js"; +import { nearestCommands } from "./nearest.js"; import type { SlashContext, SlashResult } from "./types.js"; /** Synchronous return — async work fires-and-forgets via `ctx.postInfo` to keep input non-blocking. */ @@ -50,5 +51,10 @@ export function handleSlash( ): SlashResult { const h = HANDLERS[resolveSlashAlias(cmd)]; if (h) return h(args, loop, ctx); + const suggestions = nearestCommands(cmd, Object.keys(HANDLERS)); + if (suggestions.length > 0) { + const list = suggestions.map((name) => `/${name}`).join(", "); + return { unknown: true, info: `unknown command: /${cmd} — did you mean ${list}?` }; + } return { unknown: true, info: `unknown command: /${cmd} (try /help)` }; } diff --git a/src/cli/ui/slash/nearest.ts b/src/cli/ui/slash/nearest.ts new file mode 100644 index 0000000..ec96f7d --- /dev/null +++ b/src/cli/ui/slash/nearest.ts @@ -0,0 +1,38 @@ +export type NearestCommandOptions = { + max?: number; + maxDistance?: number; +}; + +export function nearestCommands( + input: string, + all: readonly string[], + opts: NearestCommandOptions = {}, +): string[] { + if (!input) return []; + const max = opts.max ?? 3; + const maxDistance = Math.min(opts.maxDistance ?? 3, Math.floor(input.length / 2)); + if (max <= 0 || maxDistance <= 0) return []; + return all + .map((name) => ({ name, distance: levenshtein(input, name) })) + .filter((entry) => entry.distance <= maxDistance) + .sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name)) + .slice(0, max) + .map((entry) => entry.name); +} + +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (!a) return b.length; + if (!b) return a.length; + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + let next = new Array(b.length + 1).fill(0); + for (let i = 0; i < a.length; i += 1) { + next[0] = i + 1; + for (let j = 0; j < b.length; j += 1) { + const cost = a[i] === b[j] ? 0 : 1; + next[j + 1] = Math.min((next[j] ?? 0) + 1, (prev[j + 1] ?? 0) + 1, (prev[j] ?? 0) + cost); + } + [prev, next] = [next, prev]; + } + return prev[b.length] ?? 0; +} diff --git a/tests/slash-nearest.test.ts b/tests/slash-nearest.test.ts new file mode 100644 index 0000000..f80aa7f --- /dev/null +++ b/tests/slash-nearest.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { handleSlash } from "../src/cli/ui/slash/dispatch.js"; +import { nearestCommands } from "../src/cli/ui/slash/nearest.js"; +import { DeepSeekClient } from "../src/client.js"; +import { CacheFirstLoop } from "../src/loop.js"; +import { ImmutablePrefix } from "../src/memory/runtime.js"; + +function makeLoop() { + const client = new DeepSeekClient({ + apiKey: "sk-test", + fetch: (() => { + throw new Error("fetch should not run in slash-nearest tests"); + }) as unknown as typeof fetch, + }); + return new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + }); +} + +describe("nearestCommands", () => { + const commands = ["update", "sessions", "doctor", "models", "model"] as const; + + it("sorts typo matches by distance", () => { + expect(nearestCommands("upadte", commands)).toEqual(["update"]); + }); + + it("handles missing, extra, and transposed letters", () => { + expect(nearestCommands("sesions", commands)).toContain("sessions"); + expect(nearestCommands("docttor", commands)).toContain("doctor"); + expect(nearestCommands("modle", commands)).toEqual(["model", "models"]); + }); + + it("returns no matches when nothing is close enough", () => { + expect(nearestCommands("xyz123", commands)).toEqual([]); + expect(nearestCommands("x", commands)).toEqual([]); + }); +}); + +describe("handleSlash unknown suggestions", () => { + it("adds a did-you-mean hint for close matches", () => { + const r = handleSlash("upadte", [], makeLoop()); + expect(r.unknown).toBe(true); + expect(r.info).toBe("unknown command: /upadte — did you mean /update?"); + }); + + it("keeps the /help fallback when no command is close", () => { + const r = handleSlash("xyz123", [], makeLoop()); + expect(r.unknown).toBe(true); + expect(r.info).toBe("unknown command: /xyz123 (try /help)"); + }); +});