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
10 changes: 5 additions & 5 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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"),
});
}

Expand Down
12 changes: 6 additions & 6 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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 = .{""},
Expand Down
33 changes: 27 additions & 6 deletions src/agent/Agent.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
76 changes: 76 additions & 0 deletions src/agent/Command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {} };
}

Expand All @@ -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 = "<url>", .starter = " https://www." },
.{ .name = "CLICK", .args = "'<selector>'", .starter = " '" },
.{ .name = "TYPE", .args = "'<selector>' '<value>'", .starter = " '" },
.{ .name = "WAIT", .args = "'<selector>'", .starter = " '" },
.{ .name = "SCROLL", .args = "[x] [y]" },
.{ .name = "HOVER", .args = "'<selector>'", .starter = " '" },
.{ .name = "SELECT", .args = "'<selector>' '<value>'", .starter = " '" },
.{ .name = "CHECK", .args = "'<selector>' [true|false]", .starter = " '" },
.{ .name = "TREE", .args = null },
.{ .name = "MARKDOWN", .args = null },
.{ .name = "EXTRACT", .args = "'<selector>'", .starter = " '" },
.{ .name = "EVAL", .args = "'<script>'", .starter = " '" },
.{ .name = "LOGIN", .args = null },
.{ .name = "ACCEPT_COOKIES", .args = null },
};

/// If the first word of `line` is a recognized PandaScript keyword, returns
/// its entry. Used by the REPL to surface a syntax error when `Command.parse`
/// rejects a line whose first word *looked* like a command — either an argful
/// keyword missing its args, or an argless keyword followed by junk.
pub fn keywordSyntax(line: []const u8) ?KeywordSyntax {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
const end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len;
const word = trimmed[0..end];
for (keywords) |kc| {
if (std.mem.eql(u8, word, kc.name)) return kc;
}
return null;
}

/// Iterator for parsing a script file, handling multi-line EVAL """ ... """ blocks.
pub const ScriptIterator = struct {
allocator: std.mem.Allocator,
Expand Down Expand Up @@ -582,6 +630,34 @@ test "parse GOTO missing url" {
try std.testing.expect(cmd == .natural_language);
}

test "keywordSyntax: argful keyword without args returns its shape" {
const k = keywordSyntax("CLICK").?;
try std.testing.expectEqualStrings("CLICK", k.name);
try std.testing.expectEqualStrings("'<selector>'", k.args.?);
}

test "keywordSyntax: trailing whitespace tolerated" {
try std.testing.expect(keywordSyntax(" GOTO ") != null);
}

test "keywordSyntax: argless keyword returns entry with null args" {
const k = keywordSyntax("LOGIN").?;
try std.testing.expectEqualStrings("LOGIN", k.name);
try std.testing.expect(k.args == null);
}

test "keywordSyntax: unknown word returns null" {
try std.testing.expect(keywordSyntax("FOOBAR") == null);
try std.testing.expect(keywordSyntax("click the button") == null);
}

test "parse argless keyword with trailing junk falls through to natural_language" {
try std.testing.expect(parse("LOGIN abc") == .natural_language);
try std.testing.expect(parse("TREE foo") == .natural_language);
try std.testing.expect(parse("MARKDOWN x") == .natural_language);
try std.testing.expect(parse("ACCEPT_COOKIES y") == .natural_language);
}

test "parse CLICK quoted" {
const cmd = parse("CLICK \"Login\"");
try std.testing.expectEqualStrings("Login", cmd.click);
Expand Down
4 changes: 4 additions & 0 deletions src/agent/SlashCommand.zig
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub const SchemaInfo = struct {
hints: []const HintSlot,
};

/// Meta slash commands handled directly by the agent (not by ToolExecutor).
/// Kept in sync with `handleSlash` in Agent.zig.
pub const meta_names = [_][:0]const u8{ "help", "quit" };

pub const ParseError = error{
MissingName,
UnknownTool,
Expand Down
Loading
Loading