diff --git a/build.zig b/build.zig index eabf5fbf80..9a9e6c2547 100644 --- a/build.zig +++ b/build.zig @@ -88,7 +88,7 @@ pub fn build(b: *Build) !void { try linkCurl(b, mod, enable_tsan); try linkHtml5Ever(b, mod); linkZenai(b, mod); - linkLinenoise(b, mod); + linkIsocline(b, mod); break :blk mod; }; @@ -1005,11 +1005,11 @@ fn linkZenai(b: *Build, mod: *Build.Module) void { mod.addImport("zenai", dep.module("zenai")); } -fn linkLinenoise(b: *Build, mod: *Build.Module) void { - const dep = b.dependency("linenoise", .{}); - mod.addIncludePath(dep.path("")); +fn linkIsocline(b: *Build, mod: *Build.Module) void { + const dep = b.dependency("isocline", .{}); + mod.addIncludePath(dep.path("include")); mod.addCSourceFile(.{ - .file = dep.path("linenoise.c"), + .file = dep.path("src/isocline.c"), }); } diff --git a/build.zig.zon b/build.zig.zon index 5ec074d6b2..3f49aae823 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -34,17 +34,17 @@ .url = "https://github.com/allyourcodebase/sqlite3/archive/8f840560eae88ab66668c6827c64ffbd0d74ef37.tar.gz", .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, - .linenoise = .{ - .url = "https://github.com/antirez/linenoise/archive/refs/tags/2.0.tar.gz", - .hash = "N-V-__8AAJ4HAgCX79UDBfNwhqAqUVoGC44ib6UYa18q6oa_", + .libidn2 = .{ + .url = "https://ftp.gnu.org/gnu/libidn/libidn2-2.3.8.tar.gz", + .hash = "N-V-__8AABGOuAC_dhAN07kfoP4dycCFi8Bka4O-tuhriNH8", }, .zenai = .{ .url = "git+https://github.com/lightpanda-io/zenai.git#fb7d356617dcf7559c2d47d56b73d9b082f81b9b", .hash = "zenai-0.0.0-iOY_VOebAwA19L7IyQKx8CJRDbNwf75jN_nJ_F3OFD8m", }, - .libidn2 = .{ - .url = "https://ftp.gnu.org/gnu/libidn/libidn2-2.3.8.tar.gz", - .hash = "N-V-__8AABGOuAC_dhAN07kfoP4dycCFi8Bka4O-tuhriNH8", + .isocline = .{ + .url = "git+https://github.com/arrufat/isocline.git#c3cb438b475c0c2cb8eceef0f7ffd0fbf41bfbb3", + .hash = "N-V-__8AAM8zEwBkQl7O6YrCWvBsnRiOKcdRi_6UoimWlDO3", }, }, .paths = .{""}, diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 16011af7d7..2bf1fbb654 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -228,7 +228,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal); - Terminal.setSlashSchemas(slash_schemas); + self.terminal.attachCompleter(slash_schemas); return self; } @@ -300,8 +300,8 @@ fn runRepl(self: *Self) void { } repl: while (true) { - const line = self.terminal.readLine("> ") orelse break; - defer self.terminal.freeLine(line); + const line = Terminal.readLine("") orelse break; + defer Terminal.freeLine(line); if (line.len == 0) continue; @@ -312,6 +312,21 @@ fn runRepl(self: *Self) void { const cmd = Command.parse(line); + // Distinguish "you mistyped a PandaScript command" from "this is + // natural language for the LLM". Both fall through to + // `.natural_language` in Command.parse, but the first should never + // hit the LLM-needed error path. + if (std.meta.activeTag(cmd) == .natural_language) { + if (Command.keywordSyntax(line)) |kc| { + if (kc.args) |args| { + self.terminal.printErrorFmt("Usage: {s} {s}", .{ kc.name, args }); + } else { + self.terminal.printErrorFmt("{s} takes no arguments", .{kc.name}); + } + continue; + } + } + if (cmd.needsLlm() and self.ai_client == null) { self.terminal.printError("This command needs an LLM. Set an API key or pass --provider (and drop --no-llm if you set it). PandaScript commands (GOTO, CLICK, EXTRACT, ...) work without one."); continue; @@ -343,9 +358,7 @@ fn handleSlash(self: *Self, body: []const u8) bool { const name = split.name; const rest = split.rest; - if (std.mem.eql(u8, name, "quit")) { - return true; - } + if (std.mem.eql(u8, name, "quit")) return true; if (std.mem.eql(u8, name, "help")) { self.printSlashHelp(rest); return false; @@ -400,6 +413,14 @@ fn printSlashHelp(self: *Self, target: []const u8) void { return; } const lookup = if (target[0] == '/') target[1..] else target; + if (std.ascii.eqlIgnoreCase(lookup, "help")) { + self.terminal.printInfo("/help [name] — show help for a slash command, or list all when [name] is omitted"); + return; + } + if (std.ascii.eqlIgnoreCase(lookup, "quit")) { + self.terminal.printInfo("/quit — exit the REPL"); + return; + } const schema = SlashCommand.findSchema(self.slash_schemas, lookup) orelse { self.terminal.printErrorFmt("unknown tool: {s}", .{lookup}); return; diff --git a/src/agent/Command.zig b/src/agent/Command.zig index 6449c184f3..03630d39bd 100644 --- a/src/agent/Command.zig +++ b/src/agent/Command.zig @@ -189,10 +189,12 @@ pub fn parse(line: []const u8) Command { } if (std.mem.eql(u8, cmd_word, "TREE")) { + if (rest.len > 0) return .{ .natural_language = trimmed }; return .{ .tree = {} }; } if (std.mem.eql(u8, cmd_word, "MARKDOWN")) { + if (rest.len > 0) return .{ .natural_language = trimmed }; return .{ .markdown = {} }; } @@ -207,16 +209,62 @@ pub fn parse(line: []const u8) Command { } if (std.mem.eql(u8, cmd_word, "LOGIN")) { + if (rest.len > 0) return .{ .natural_language = trimmed }; return .{ .login = {} }; } if (std.mem.eql(u8, cmd_word, "ACCEPT_COOKIES")) { + if (rest.len > 0) return .{ .natural_language = trimmed }; return .{ .accept_cookies = {} }; } return .{ .natural_language = trimmed }; } +pub const KeywordSyntax = struct { + name: []const u8, + /// Null for argless commands; the agent renders a different error. + args: ?[]const u8, + /// Tab-completion suffix shown after a fully-typed keyword. Designed so + /// Tab does something useful (open the quote, insert a URL scheme) rather + /// than inserting an angle-bracket template. Null when no good starter + /// exists (numeric args, argless commands). + starter: ?[]const u8 = null, +}; + +/// Single source of truth for PandaScript keyword names — consumed by the +/// parser, the REPL highlighter, and Tab completion. +pub const keywords = [_]KeywordSyntax{ + .{ .name = "GOTO", .args = "", .starter = " https://www." }, + .{ .name = "CLICK", .args = "''", .starter = " '" }, + .{ .name = "TYPE", .args = "'' ''", .starter = " '" }, + .{ .name = "WAIT", .args = "''", .starter = " '" }, + .{ .name = "SCROLL", .args = "[x] [y]" }, + .{ .name = "HOVER", .args = "''", .starter = " '" }, + .{ .name = "SELECT", .args = "'' ''", .starter = " '" }, + .{ .name = "CHECK", .args = "'' [true|false]", .starter = " '" }, + .{ .name = "TREE", .args = null }, + .{ .name = "MARKDOWN", .args = null }, + .{ .name = "EXTRACT", .args = "''", .starter = " '" }, + .{ .name = "EVAL", .args = "'