Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/cli/ui/slash/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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)` };
}
38 changes: 38 additions & 0 deletions src/cli/ui/slash/nearest.ts
Original file line number Diff line number Diff line change
@@ -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<number>(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;
}
52 changes: 52 additions & 0 deletions tests/slash-nearest.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});
});
Loading