diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 3bf662f1..38d08b0c 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -740,7 +740,7 @@ fn runActionEntry(self: *Agent, sa: std.mem.Allocator, entry: Command.ScriptIter /// Re-run a verification-failed command with bounded backoff. Returns true /// once both execution and verification pass, false after 3 attempts. -fn retryCommand(self: *Agent, ca: std.mem.Allocator, cmd: Command.Command) bool { +fn retryCommand(self: *Agent, ca: std.mem.Allocator, cmd: Command) bool { for (0..3) |i| { std.Thread.sleep((500 + i * 250) * std.time.ns_per_ms); self.terminal.printInfo("Retrying command..."); @@ -768,7 +768,7 @@ fn flushReplacements(self: *Agent, path: []const u8, content: []const u8, replac ); } -fn isRetryable(cmd: Command.Command) bool { +fn isRetryable(cmd: Command) bool { return switch (cmd) { .type_cmd, .check, .select => true, else => false, @@ -814,20 +814,9 @@ fn pruneMessages(self: *Agent) void { self.message_arena = new_arena; } -/// Self-heal must only patch the current page; navigation and arbitrary -/// scripting are blocked even if the model emits them via `goto` / `eval`. -/// docs/agent.md guarantees "no navigation away from the current page". -/// Exhaustive on purpose: adding a `Command` variant must force a decision here. -fn isHealAllowed(cmd: Command.Command) bool { - return switch (cmd) { - .click, .hover, .wait, .type_cmd, .select, .check, .scroll, .extract => true, - .goto, .eval_js, .tree, .markdown, .login, .accept_cookies, .comment, .natural_language => false, - }; -} - /// Runs a single LLM turn, captures the commands it called without recording /// them — so the caller can splice healed commands into the script directly. -fn runHealTurn(self: *Agent, arena: std.mem.Allocator, prompt: []const u8) ![]Command.Command { +fn runHealTurn(self: *Agent, arena: std.mem.Allocator, prompt: []const u8) ![]Command { const provider_client = self.ai_client orelse return error.NoAiClient; const ma = self.message_arena.allocator(); @@ -859,12 +848,12 @@ fn runHealTurn(self: *Agent, arena: std.mem.Allocator, prompt: []const u8) ![]Co self.terminal.spinner.stop(); defer result.deinit(); - var cmds: std.ArrayList(Command.Command) = .empty; + var cmds: std.ArrayList(Command) = .empty; for (result.tool_calls_made) |tc| { if (tc.is_error) continue; const args = tc.arguments orelse continue; const cmd = Command.fromToolCall(tc.name, args) orelse continue; - if (!isHealAllowed(cmd)) { + if (!cmd.canHeal()) { self.terminal.printInfoFmt( "self-heal: ignoring {s} (navigation and eval are not allowed during heal)", .{tc.name}, @@ -881,7 +870,7 @@ fn runHealTurn(self: *Agent, arena: std.mem.Allocator, prompt: []const u8) ![]Co return cmds.toOwnedSlice(arena); } -fn attemptSelfHeal(self: *Agent, arena: std.mem.Allocator, failed_command: []const u8, verify_context: ?[]const u8, context_comment: ?[]const u8) ?[]Command.Command { +fn attemptSelfHeal(self: *Agent, arena: std.mem.Allocator, failed_command: []const u8, verify_context: ?[]const u8, context_comment: ?[]const u8) ?[]Command { // Build the prompt in `arena` (the caller's per-replay arena), not in // `message_arena`. The prompt is re-used across attempts, so it must // survive arena rebuilds done between failed attempts. @@ -1399,24 +1388,25 @@ fn promptNumberedChoice(header: []const u8, items: []const []const u8, default: // --- Tests --- -test "isHealAllowed: only page-local DOM commands are allowed" { - try std.testing.expect(isHealAllowed(.{ .click = ".btn" })); - try std.testing.expect(isHealAllowed(.{ .hover = ".menu" })); - try std.testing.expect(isHealAllowed(.{ .wait = ".loaded" })); - try std.testing.expect(isHealAllowed(.{ .type_cmd = .{ .selector = "#u", .value = "x" } })); - try std.testing.expect(isHealAllowed(.{ .select = .{ .selector = "#s", .value = "x" } })); - try std.testing.expect(isHealAllowed(.{ .check = .{ .selector = "#c", .checked = true } })); - try std.testing.expect(isHealAllowed(.{ .scroll = .{ .x = 0, .y = 100 } })); - try std.testing.expect(isHealAllowed(.{ .extract = "schema" })); +test "canHeal: only page-local DOM commands are allowed" { + const C = Command; + try std.testing.expect((C{ .click = ".btn" }).canHeal()); + try std.testing.expect((C{ .hover = ".menu" }).canHeal()); + try std.testing.expect((C{ .wait = ".loaded" }).canHeal()); + try std.testing.expect((C{ .type_cmd = .{ .selector = "#u", .value = "x" } }).canHeal()); + try std.testing.expect((C{ .select = .{ .selector = "#s", .value = "x" } }).canHeal()); + try std.testing.expect((C{ .check = .{ .selector = "#c", .checked = true } }).canHeal()); + try std.testing.expect((C{ .scroll = .{ .x = 0, .y = 100 } }).canHeal()); + try std.testing.expect((C{ .extract = "schema" }).canHeal()); - try std.testing.expect(!isHealAllowed(.{ .goto = "https://x" })); - try std.testing.expect(!isHealAllowed(.{ .eval_js = "alert(1)" })); - try std.testing.expect(!isHealAllowed(.{ .natural_language = "do x" })); - try std.testing.expect(!isHealAllowed(.login)); - try std.testing.expect(!isHealAllowed(.accept_cookies)); - try std.testing.expect(!isHealAllowed(.comment)); - try std.testing.expect(!isHealAllowed(.tree)); - try std.testing.expect(!isHealAllowed(.markdown)); + try std.testing.expect(!(C{ .goto = "https://x" }).canHeal()); + try std.testing.expect(!(C{ .eval_js = "alert(1)" }).canHeal()); + try std.testing.expect(!(C{ .natural_language = "do x" }).canHeal()); + try std.testing.expect(!(C{ .login = {} }).canHeal()); + try std.testing.expect(!(C{ .accept_cookies = {} }).canHeal()); + try std.testing.expect(!(C{ .comment = {} }).canHeal()); + try std.testing.expect(!(C{ .tree = {} }).canHeal()); + try std.testing.expect(!(C{ .markdown = {} }).canHeal()); } test { diff --git a/src/agent/CommandRunner.zig b/src/agent/CommandRunner.zig index d886b02d..07b11696 100644 --- a/src/agent/CommandRunner.zig +++ b/src/agent/CommandRunner.zig @@ -38,14 +38,14 @@ pub fn init(tool_executor: *ToolExecutor, terminal: *Terminal) CommandRunner { /// Caller contract: `cmd` must not be `.natural_language`, `.comment`, /// `.login`, or `.accept_cookies` — those are filtered upstream (see /// `Agent.runRepl`) because they have no tool mapping. -pub fn executeWithResult(self: *CommandRunner, arena: std.mem.Allocator, cmd: Command.Command) browser_tools.ToolResult { +pub fn executeWithResult(self: *CommandRunner, arena: std.mem.Allocator, cmd: Command) browser_tools.ToolResult { switch (cmd) { .extract => |schema| return self.execExtract(arena, schema), .eval_js => |script| return browser_tools.ToolResult.unwrap(self.tool_executor.callEval(arena, script)), else => {}, } - const tc = (Command.toToolCall(arena, cmd, browser_tools.substituteEnvVars) catch + const tc = (cmd.toToolCall(arena, browser_tools.substituteEnvVars) catch return .{ .text = "out of memory", .is_error = true }) orelse return .{ .text = "internal: command has no tool mapping", .is_error = true }; return self.tool_executor.callValue(arena, tc.name, tc.args) catch |err| .{ @@ -56,7 +56,7 @@ pub fn executeWithResult(self: *CommandRunner, arena: std.mem.Allocator, cmd: Co /// Data output (EXTRACT/EVAL/MARKDOWN/TREE) → stdout on success; everything /// else, including failures from those same commands, → stderr. -pub fn printResult(self: *CommandRunner, cmd: Command.Command, result: browser_tools.ToolResult) void { +pub fn printResult(self: *CommandRunner, cmd: Command, result: browser_tools.ToolResult) void { if (cmd.producesData() and !result.is_error) { self.terminal.printAssistant(result.text); } else { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 3bff9bd2..1d03e44f 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -262,7 +262,7 @@ fn handleScriptStep(server: *Server, arena: std.mem.Allocator, id: std.json.Valu } // No recording on script_step: replay must not double-record. - const tc = (try Command.toToolCall(arena, cmd, Command.noSubstitute)) orelse { + const tc = (try cmd.toToolCall(arena, Command.noSubstitute)) orelse { return sendErrorContent(server, id, "command has no browser-tool mapping"); }; diff --git a/src/script.zig b/src/script.zig index f4690e35..aeccea99 100644 --- a/src/script.zig +++ b/src/script.zig @@ -31,7 +31,7 @@ const std = @import("std"); -pub const Command = @import("script/Command.zig"); +pub const Command = @import("script/command.zig").Command; pub const Recorder = @import("script/Recorder.zig"); pub const Verifier = @import("script/Verifier.zig"); @@ -188,7 +188,7 @@ pub fn formatHealReplacement( arena: std.mem.Allocator, original_span: []const u8, raw_line: []const u8, - cmds: []const Command.Command, + cmds: []const Command, ) !Replacement { std.debug.assert(cmds.len > 0); const lines = try arena.alloc([]const u8, cmds.len); @@ -360,7 +360,7 @@ test "formatHealReplacement: single command produces one-line replacement" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); - const cmds = [_]Command.Command{.{ .click = "#submit-v2" }}; + const cmds = [_]Command{.{ .click = "#submit-v2" }}; const replacement = try formatHealReplacement( arena.allocator(), "CLICK '#submit'\n", @@ -379,7 +379,7 @@ test "formatHealReplacement: multiple commands produce multi-line replacement" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); - const cmds = [_]Command.Command{ + const cmds = [_]Command{ .{ .click = ".cookie-accept" }, .{ .click = "#submit-v2" }, }; diff --git a/src/script/Recorder.zig b/src/script/Recorder.zig index 4aff75b2..4148d527 100644 --- a/src/script/Recorder.zig +++ b/src/script/Recorder.zig @@ -20,7 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const log = lp.log; const testing = @import("../testing.zig"); -const Command = @import("Command.zig"); +const Command = @import("command.zig").Command; const Recorder = @This(); @@ -70,13 +70,13 @@ pub fn isActive(self: *const Recorder) bool { return self.file != null; } -pub fn record(self: *Recorder, cmd: Command.Command) void { +pub fn record(self: *Recorder, cmd: Command) void { if (self.file == null) return; if (!cmd.isRecorded()) return; self.tryRecord(cmd) catch |err| self.disable(err); } -fn tryRecord(self: *Recorder, cmd: Command.Command) !void { +fn tryRecord(self: *Recorder, cmd: Command) !void { self.buf.clearRetainingCapacity(); try cmd.format(&self.buf.writer); try self.buf.writer.writeByte('\n'); diff --git a/src/script/Verifier.zig b/src/script/Verifier.zig index 53298d9c..b4d723bf 100644 --- a/src/script/Verifier.zig +++ b/src/script/Verifier.zig @@ -19,7 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const browser_tools = lp.tools; -const Command = @import("Command.zig"); +const Command = @import("command.zig").Command; const CDPNode = @import("../cdp/Node.zig"); const Verifier = @This(); @@ -54,7 +54,7 @@ const failed_reason_oom = "verification failed (out of memory while formatting r /// when the command did not hard-fail (ToolResult.is_error == false). /// Commands without a dedicated verifier return `.inconclusive` so callers /// can distinguish "no verification available" from "explicitly verified". -pub fn verify(self: *Verifier, arena: std.mem.Allocator, cmd: Command.Command) VerifyResult { +pub fn verify(self: *Verifier, arena: std.mem.Allocator, cmd: Command) VerifyResult { return switch (cmd) { .type_cmd => |args| self.verifyFill(arena, args.selector, args.value), .check => |args| self.verifyCheck(arena, args.selector, args.checked), diff --git a/src/script/Command.zig b/src/script/command.zig similarity index 57% rename from src/script/Command.zig rename to src/script/command.zig index 35b29266..98a16c2a 100644 --- a/src/script/Command.zig +++ b/src/script/command.zig @@ -19,22 +19,22 @@ const std = @import("std"); const lp = @import("lightpanda"); -pub const TypeArgs = struct { +const TypeArgs = struct { selector: []const u8, value: []const u8, }; -pub const ScrollArgs = struct { +const ScrollArgs = struct { x: i32 = 0, y: i32 = 0, }; -pub const SelectArgs = struct { +const SelectArgs = struct { selector: []const u8, value: []const u8, }; -pub const CheckArgs = struct { +const CheckArgs = struct { selector: []const u8, checked: bool, }; @@ -85,6 +85,79 @@ pub const Command = union(enum) { }; } + /// Self-heal must only patch the current page; navigation and arbitrary + /// scripting are blocked even if the model emits them via `goto` / `eval`. + /// docs/agent.md guarantees "no navigation away from the current page". + /// Exhaustive on purpose: adding a `Command` variant must force a decision here. + pub fn canHeal(self: Command) bool { + return switch (self) { + .click, .hover, .wait, .type_cmd, .select, .check, .scroll, .extract => true, + .goto, .eval_js, .tree, .markdown, .login, .accept_cookies, .comment, .natural_language => false, + }; + } + + /// Map a Command to its (tool_name, JSON args) representation. Returns + /// null for variants without a 1:1 tool mapping (login, accept_cookies, + /// natural_language, comment). + /// + /// `substitute` is applied to selector-like fields. The `value` field of + /// `type_cmd` is intentionally NOT substituted: `execFill` in + /// `browser/tools.zig` substitutes it itself so the secret never appears + /// in the result text echoed back to the LLM/terminal. + pub fn toToolCall(self: Command, arena: std.mem.Allocator, substitute: SubstituteFn) std.mem.Allocator.Error!?ToolCall { + const Action = lp.tools.Action; + var obj: std.json.ObjectMap = .init(arena); + switch (self) { + .goto => |url| { + try obj.put("url", .{ .string = try substitute(arena, url) }); + return .{ .name = @tagName(Action.goto), .args = .{ .object = obj } }; + }, + .click => |sel| { + try obj.put("selector", .{ .string = try substitute(arena, sel) }); + return .{ .name = @tagName(Action.click), .args = .{ .object = obj } }; + }, + .type_cmd => |args| { + try obj.put("selector", .{ .string = try substitute(arena, args.selector) }); + try obj.put("value", .{ .string = args.value }); + return .{ .name = @tagName(Action.fill), .args = .{ .object = obj } }; + }, + .wait => |sel| { + try obj.put("selector", .{ .string = sel }); + return .{ .name = @tagName(Action.waitForSelector), .args = .{ .object = obj } }; + }, + .scroll => |args| { + try obj.put("x", .{ .integer = args.x }); + try obj.put("y", .{ .integer = args.y }); + return .{ .name = @tagName(Action.scroll), .args = .{ .object = obj } }; + }, + .hover => |sel| { + try obj.put("selector", .{ .string = try substitute(arena, sel) }); + return .{ .name = @tagName(Action.hover), .args = .{ .object = obj } }; + }, + .select => |args| { + try obj.put("selector", .{ .string = try substitute(arena, args.selector) }); + try obj.put("value", .{ .string = try substitute(arena, args.value) }); + return .{ .name = @tagName(Action.selectOption), .args = .{ .object = obj } }; + }, + .check => |args| { + try obj.put("selector", .{ .string = try substitute(arena, args.selector) }); + try obj.put("checked", .{ .bool = args.checked }); + return .{ .name = @tagName(Action.setChecked), .args = .{ .object = obj } }; + }, + .tree => return .{ .name = @tagName(Action.tree), .args = null }, + .markdown => return .{ .name = @tagName(Action.markdown), .args = null }, + .eval_js => |script| { + try obj.put("script", .{ .string = script }); + return .{ .name = @tagName(Action.eval), .args = .{ .object = obj } }; + }, + .extract => |schema| { + try obj.put("schema", .{ .string = try substitute(arena, schema) }); + return .{ .name = @tagName(Action.extract), .args = .{ .object = obj } }; + }, + .natural_language, .comment, .login, .accept_cookies => return null, + } + } + /// Serializes back to PandaScript. Every string argument is wrapped in /// content-aware quotes so the output round-trips through `parse()`: /// - single quotes by default, @@ -123,6 +196,338 @@ pub const Command = union(enum) { .natural_language => |text| try writer.writeAll(text), } } + + /// Parse a line of REPL input into a PandaScript command. + /// Unrecognized input is returned as `.natural_language`. + /// For multi-line EVAL blocks in scripts, use `ScriptParser`. + pub fn parse(line: []const u8) Command { + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0) return .{ .natural_language = trimmed }; + + if (trimmed[0] == '#') return .{ .comment = {} }; + + const split = splitHead(trimmed); + const cmd_word = split.head; + const rest = split.rest; + + if (std.mem.eql(u8, cmd_word, "GOTO")) { + if (rest.len == 0) return .{ .natural_language = trimmed }; + return .{ .goto = rest }; + } + + if (std.mem.eql(u8, cmd_word, "CLICK")) { + const arg = trimMatchingQuotes(rest) orelse return .{ .natural_language = trimmed }; + return .{ .click = arg }; + } + + if (std.mem.eql(u8, cmd_word, "TYPE")) { + const first = extractQuotedWithRemainder(rest) orelse return .{ .natural_language = trimmed }; + const second_arg = std.mem.trim(u8, first.remainder, &std.ascii.whitespace); + const second = trimMatchingQuotes(second_arg) orelse return .{ .natural_language = trimmed }; + return .{ .type_cmd = .{ .selector = first.value, .value = second } }; + } + + if (std.mem.eql(u8, cmd_word, "WAIT")) { + const arg = trimMatchingQuotes(rest) orelse return .{ .natural_language = trimmed }; + return .{ .wait = arg }; + } + + if (std.mem.eql(u8, cmd_word, "SCROLL")) { + // SCROLL → scroll to (0, 0) + // SCROLL 100 → scroll y=100 + // SCROLL 50 200 → scroll x=50, y=200 + if (rest.len == 0) return .{ .scroll = .{} }; + var it = std.mem.tokenizeAny(u8, rest, &std.ascii.whitespace); + const first = it.next() orelse return .{ .scroll = .{} }; + const second = it.next(); + if (second) |s| { + const x = std.fmt.parseInt(i32, first, 10) catch return .{ .natural_language = trimmed }; + const y = std.fmt.parseInt(i32, s, 10) catch return .{ .natural_language = trimmed }; + return .{ .scroll = .{ .x = x, .y = y } }; + } + const y = std.fmt.parseInt(i32, first, 10) catch return .{ .natural_language = trimmed }; + return .{ .scroll = .{ .x = 0, .y = y } }; + } + + if (std.mem.eql(u8, cmd_word, "HOVER")) { + const arg = trimMatchingQuotes(rest) orelse return .{ .natural_language = trimmed }; + return .{ .hover = arg }; + } + + if (std.mem.eql(u8, cmd_word, "SELECT")) { + const first = extractQuotedWithRemainder(rest) orelse return .{ .natural_language = trimmed }; + const second_arg = std.mem.trim(u8, first.remainder, &std.ascii.whitespace); + const second = trimMatchingQuotes(second_arg) orelse return .{ .natural_language = trimmed }; + return .{ .select = .{ .selector = first.value, .value = second } }; + } + + if (std.mem.eql(u8, cmd_word, "CHECK")) { + // CHECK '' → checked = true + // CHECK '' true → checked = true + // CHECK '' false → checked = false + const first = extractQuotedWithRemainder(rest) orelse return .{ .natural_language = trimmed }; + const after = std.mem.trim(u8, first.remainder, &std.ascii.whitespace); + if (after.len == 0) { + return .{ .check = .{ .selector = first.value, .checked = true } }; + } + if (std.ascii.eqlIgnoreCase(after, "true")) { + return .{ .check = .{ .selector = first.value, .checked = true } }; + } + if (std.ascii.eqlIgnoreCase(after, "false")) { + return .{ .check = .{ .selector = first.value, .checked = false } }; + } + return .{ .natural_language = trimmed }; + } + + 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 = {} }; + } + + if (std.mem.eql(u8, cmd_word, "EXTRACT")) { + const arg = trimMatchingQuotes(rest) orelse return .{ .natural_language = trimmed }; + return .{ .extract = arg }; + } + + if (std.mem.eql(u8, cmd_word, "EVAL")) { + const arg = trimMatchingQuotes(rest) orelse return .{ .natural_language = trimmed }; + return .{ .eval_js = arg }; + } + + 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 }; + } + + /// Inverse of `toToolCall`: map an LLM tool call into a Command, or return + /// null if the tool name doesn't correspond to a PandaScript command. + /// Variants emitted by `toToolCall` round-trip through this. + pub fn fromToolCall(tool_name: []const u8, arguments: std.json.Value) ?Command { + const Action = lp.tools.Action; + const action = std.meta.stringToEnum(Action, tool_name) orelse return null; + const obj = switch (arguments) { + .object => |o| o, + else => return null, + }; + + return switch (action) { + .goto => .{ .goto = getJsonString(obj, "url") orelse return null }, + .click => .{ .click = getJsonString(obj, "selector") orelse return null }, + .hover => .{ .hover = getJsonString(obj, "selector") orelse return null }, + .eval => .{ .eval_js = getJsonString(obj, "script") orelse return null }, + .extract => .{ .extract = getJsonString(obj, "schema") orelse return null }, + .waitForSelector => .{ .wait = getJsonString(obj, "selector") orelse return null }, + .fill => .{ .type_cmd = .{ + .selector = getJsonString(obj, "selector") orelse return null, + .value = getJsonString(obj, "value") orelse return null, + } }, + .selectOption => .{ .select = .{ + .selector = getJsonString(obj, "selector") orelse return null, + .value = getJsonString(obj, "value") orelse return null, + } }, + .setChecked => .{ .check = .{ + .selector = getJsonString(obj, "selector") orelse return null, + .checked = switch (obj.get("checked") orelse return null) { + .bool => |b| b, + else => return null, + }, + } }, + .scroll => blk: { + if (obj.get("backendNodeId") != null) break :blk null; + break :blk .{ .scroll = .{ .x = getJsonI32(obj, "x", 0), .y = getJsonI32(obj, "y", 0) } }; + }, + else => null, + }; + } + + pub fn noSubstitute(_: std.mem.Allocator, input: []const u8) std.mem.Allocator.Error![]const u8 { + return input; + } + + /// 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 word = splitHead(std.mem.trim(u8, line, &std.ascii.whitespace)).head; + for (keywords) |kc| { + if (std.mem.eql(u8, word, kc.name)) return kc; + } + return null; + } + + /// Walks a PandaScript command body (the text after the keyword and its + /// separating space) and reports how many positional args have been + /// completed. Quote-aware: handles single, double, and triple-quoted strings. + /// An unterminated quote sets `at_boundary = false` so the hint suppresses + /// while the user is still inside the string. A bare token without trailing + /// whitespace likewise suppresses (cursor is mid-token). + pub fn analyzePandaBody(body: []const u8) BodyCursor { + var i: usize = 0; + var complete: usize = 0; + while (i < body.len) { + while (i < body.len and std.ascii.isWhitespace(body[i])) : (i += 1) {} + if (i >= body.len) break; + const ch = body[i]; + if (ch == '\'' or ch == '"') { + if (QuoteType.fromPrefix(body[i..])) |tq| { + const lit = tq.toLiteral(); + const end_idx = std.mem.indexOfPos(u8, body, i + lit.len, lit) orelse + return .{ .complete_args = complete, .at_boundary = false }; + i = end_idx + lit.len; + } else { + const end_idx = std.mem.indexOfScalarPos(u8, body, i + 1, ch) orelse + return .{ .complete_args = complete, .at_boundary = false }; + i = end_idx + 1; + } + complete += 1; + } else { + while (i < body.len and !std.ascii.isWhitespace(body[i])) : (i += 1) {} + complete += 1; + } + } + const boundary = body.len == 0 or std.ascii.isWhitespace(body[body.len - 1]); + return .{ .complete_args = complete, .at_boundary = boundary }; + } + + pub const KeywordSyntax = struct { + name: []const u8, + /// Null for argless commands; the agent renders a different error. + args: ?[]const u8, + /// Pre-rendered positional-arg fragments shown progressively in the inline + /// hint as the user fills them in. Empty for argless commands. The visual + /// notation matches `args` (e.g. `''` for quoted, `[x]` for + /// optional bare). Drives `analyzePandaBody`-based hint narrowing. + params: []const []const u8 = &.{}, + }; + + /// 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 = "", .params = &.{""} }, + .{ .name = "CLICK", .args = selector_arg, .params = &.{selector_arg} }, + .{ .name = "TYPE", .args = selector_arg ++ " " ++ value_arg, .params = &.{ selector_arg, value_arg } }, + .{ .name = "WAIT", .args = selector_arg, .params = &.{selector_arg} }, + .{ .name = "SCROLL", .args = "[x] [y]", .params = &.{ "[x]", "[y]" } }, + .{ .name = "HOVER", .args = selector_arg, .params = &.{selector_arg} }, + .{ .name = "SELECT", .args = selector_arg ++ " " ++ value_arg, .params = &.{ selector_arg, value_arg } }, + .{ .name = "CHECK", .args = selector_arg ++ " [true|false]", .params = &.{ selector_arg, "[true|false]" } }, + .{ .name = "TREE", .args = null }, + .{ .name = "MARKDOWN", .args = null }, + .{ .name = "EXTRACT", .args = schema_arg, .params = &.{schema_arg} }, + .{ .name = "EVAL", .args = "'