diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 40524f4a..0255e924 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -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}", diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 5c20098c..3af5dbff 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -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;