agent: suggest closest slash command on typo

Implements Levenshtein distance-based suggestions for unknown slash
commands. If a typo is within two edits of a valid command, the
terminal suggests it with "Did you mean ...?".
This commit is contained in:
Adrià Arrufat
2026-05-30 23:40:34 +02:00
parent e42862e544
commit 53ba47cbec
2 changed files with 52 additions and 5 deletions

View File

@@ -813,8 +813,7 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo
printHelpSection(&self.terminal, "\nMeta commands:", meta);
return;
}
const lookup = if (target[0] == '/') target[1..] else target;
if (SlashCommand.findMeta(lookup)) |meta| {
if (SlashCommand.findMeta(target)) |meta| {
switch (meta.tag) {
.help => self.terminal.printInfo("/help [name] — show help for a command, or list all when [name] is omitted", .{}),
.quit => self.terminal.printInfo("/quit — exit the REPL", .{}),
@@ -837,8 +836,12 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo
}
return;
}
const tool_schema = Schema.findByName(lookup) orelse {
self.terminal.printError("unknown command: {s}", .{lookup});
const tool_schema = Schema.findByName(target) orelse {
if (Terminal.closestCommand(target)) |near| {
self.terminal.printError("unknown command: {s}. Did you mean " ++ Terminal.highlightCmd("/help {s}") ++ "?", .{ target, near });
} else {
self.terminal.printError("unknown command: {s}", .{target});
}
return;
};
self.terminal.printInfo("/{s} — {s}", .{ tool_schema.tool_name, tool_schema.description });
@@ -856,7 +859,12 @@ fn printSlashParseError(self: *Agent, err: Schema.ParseError, name: []const u8,
};
}
const reason: []const u8 = switch (err) {
error.UnknownTool => return self.terminal.printError("{s}: unknown command. Try /help.", .{name}),
error.UnknownTool => {
if (Terminal.closestCommand(name)) |near| {
return self.terminal.printError("{s}: unknown command. Did you mean " ++ Terminal.highlightCmd("/{s}") ++ "? Try /help.", .{ name, near });
}
return self.terminal.printError("{s}: unknown command. Try /help.", .{name});
},
error.MissingName => return self.terminal.printError("missing command name. Try /help.", .{}),
error.MissingRequired => "missing required argument",
error.MalformedKv => "malformed key=value. Use key=value or {json}",

View File

@@ -49,6 +49,12 @@ pub const ansi = struct {
pub const clear_eol = "\x1b[K";
};
/// Wraps a comptime format fragment in the bold-cyan style used for command
/// names, so highlighted commands match the look of the `/help` listing.
pub fn highlightCmd(comptime fragment: []const u8) []const u8 {
return ansi.bold ++ ansi.cyan ++ fragment ++ ansi.reset;
}
const Verbosity = Config.AgentVerbosity;
fn atLeast(level: Verbosity, min: Verbosity) bool {
@@ -575,6 +581,39 @@ fn slashHasPrefix(name: []const u8) bool {
return false;
}
/// Closest command name to `name` within two edits, for "did you mean?"
/// suggestions on a typo'd command. Null when nothing is near enough to propose.
pub fn closestCommand(name: []const u8) ?[]const u8 {
var best: ?[]const u8 = null;
var best_dist: usize = std.math.maxInt(usize);
for (all_slash_names) |cand| {
const dist = editDistance(name, cand);
if (dist < best_dist) {
best_dist = dist;
best = cand;
}
}
return if (best_dist <= 2) best else null;
}
/// Case-insensitive Levenshtein distance via a dynamic-programming table.
/// `dp[i][j]` is the edit distance between `a[0..i]` and `b[0..j]`. Returns
/// `maxInt` for inputs exceeding the table (no slash command is that long).
fn editDistance(a: []const u8, b: []const u8) usize {
const max = 32;
if (a.len >= max or b.len >= max) return std.math.maxInt(usize);
var dp: [max][max]usize = undefined;
for (0..a.len + 1) |i| dp[i][0] = i;
for (0..b.len + 1) |j| dp[0][j] = j;
for (a, 1..) |ca, i| {
for (b, 1..) |cb, j| {
const cost: usize = if (std.ascii.toLower(ca) == std.ascii.toLower(cb)) 0 else 1;
dp[i][j] = @min(@min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
}
}
return dp[a.len][b.len];
}
fn slashHasParams(name: []const u8) bool {
if (Schema.findByName(name)) |s| return s.hints.len > 0;
if (SlashCommand.findMeta(name)) |m| return m.hint.len > 0;