Skip to content

Commit c7c545f

Browse files
committed
feat(commands): "Did you mean /X?" suggestions on typos
Unknown slash commands now compute Levenshtein distance against the registered command names and suggest the nearest match when it's a plausible typo (distance ≤ max(2, ⌈len/3⌉)). Falls back to the generic "Try /help" when nothing's close enough.
1 parent c170b4c commit c7c545f

2 files changed

Lines changed: 50 additions & 1 deletion

File tree

src/commands/registry.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ describe("CommandRegistry", () => {
8383
expect(ctx.emit).toHaveBeenCalledWith(expect.stringMatching(/unknown command/));
8484
});
8585

86+
it("suggests the closest command on a typo", async () => {
87+
const reg = new CommandRegistry();
88+
reg.register({ name: "compact", description: "", handler: () => ({ handled: true }) });
89+
const ctx = fakeCtx();
90+
await reg.dispatch("/conpact", ctx);
91+
expect(ctx.emit).toHaveBeenCalledWith(expect.stringMatching(/Did you mean \/compact\?/));
92+
});
93+
8694
it("trims whitespace around the args", async () => {
8795
const handler = vi.fn(() => ({ handled: true }));
8896
const reg = new CommandRegistry();

src/commands/registry.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,52 @@ export class CommandRegistry {
5757

5858
const command = this.get(name);
5959
if (!command) {
60-
ctx.emit(`unknown command: /${name}. Try /help.`);
60+
const suggestion = this.suggestClosest(name);
61+
ctx.emit(
62+
suggestion ? `unknown command: /${name}. Did you mean /${suggestion}?` : `unknown command: /${name}. Try /help.`,
63+
);
6164
return { handled: true };
6265
}
6366
return command.handler(args, ctx);
6467
}
68+
69+
/**
70+
* Return the registered command name with the smallest edit distance
71+
* to `query` when it's a likely typo (distance ≤ 2 or ≤ ceil(len/3),
72+
* whichever is greater). Returns undefined when nothing's close
73+
* enough — better to print "Try /help" than to mis-suggest.
74+
*/
75+
private suggestClosest(query: string): string | undefined {
76+
const q = normalize(query);
77+
if (!q) return undefined;
78+
const threshold = Math.max(2, Math.ceil(q.length / 3));
79+
let best: { name: string; dist: number } | undefined;
80+
for (const name of new Set(Array.from(this.commands.values()).map((c) => c.name))) {
81+
const dist = levenshtein(q, name);
82+
if (dist <= threshold && (!best || dist < best.dist)) {
83+
best = { name, dist };
84+
}
85+
}
86+
return best?.name;
87+
}
88+
}
89+
90+
function levenshtein(a: string, b: string): number {
91+
if (a === b) return 0;
92+
if (a.length === 0) return b.length;
93+
if (b.length === 0) return a.length;
94+
const prev = new Array(b.length + 1);
95+
const curr = new Array(b.length + 1);
96+
for (let j = 0; j <= b.length; j++) prev[j] = j;
97+
for (let i = 1; i <= a.length; i++) {
98+
curr[0] = i;
99+
for (let j = 1; j <= b.length; j++) {
100+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
101+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
102+
}
103+
for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
104+
}
105+
return prev[b.length];
65106
}
66107

67108
function normalize(name: string): string {

0 commit comments

Comments
 (0)