diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 14bdf770..d2bb9001 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -361,12 +361,11 @@ fn runRepl(self: *Self) void { .natural_language => _ = self.runTurn(.{ .prompt = line, .record_comment = line }), else => { const split = SlashCommand.splitNameRest(line) orelse continue :repl; - self.terminal.spinner.setTool(split.name, split.rest); + self.terminal.beginTool(split.name, split.rest); var arena: std.heap.ArenaAllocator = .init(self.allocator); defer arena.deinit(); const result = self.cmd_executor.executeWithResult(arena.allocator(), cmd); - if (result.failed) self.terminal.spinner.markToolFailed(); - self.terminal.spinner.cancel(); + self.terminal.endTool(!result.failed); self.cmd_executor.printResult(cmd, result); self.recorder.record(cmd); }, @@ -414,10 +413,9 @@ fn handleSlash(self: *Self, body: []const u8) bool { self.terminal.printError("eval requires a `script` argument."); return false; }; - self.terminal.spinner.setTool(schema.tool_name, rest); + self.terminal.beginTool(schema.tool_name, rest); const result = self.tool_executor.callEval(aa, eval_script); - if (result.is_error) self.terminal.spinner.markToolFailed(); - self.terminal.spinner.cancel(); + self.terminal.endTool(!result.is_error); if (result.is_error) { self.terminal.printErrorFmt("eval: {s}", .{result.text}); } else { @@ -426,13 +424,12 @@ fn handleSlash(self: *Self, body: []const u8) bool { return false; } - self.terminal.spinner.setTool(schema.tool_name, rest); + self.terminal.beginTool(schema.tool_name, rest); if (self.tool_executor.call(aa, schema.tool_name, args_json)) |result| { - self.terminal.spinner.cancel(); + self.terminal.endTool(true); self.terminal.printToolResult(schema.tool_name, result); } else |err| { - self.terminal.spinner.markToolFailed(); - self.terminal.spinner.cancel(); + self.terminal.endTool(false); self.terminal.printErrorFmt("{s}: {s}", .{ schema.tool_name, @errorName(err) }); } return false; diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 79e41e2f..f316d8d6 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -112,6 +112,18 @@ pub fn deinit(self: *Self) void { const bullet_line_fmt = "{s}●{s} {s}[tool: {s}]{s} {s}\n"; +/// Mark the start of a manual REPL tool call. Pairs with `endTool`. +pub fn beginTool(self: *Self, name: []const u8, args: []const u8) void { + self.spinner.setTool(name, args); +} + +/// Mark the end of a manual REPL tool call. On failure, flashes the spinner +/// label red before clearing it. +pub fn endTool(self: *Self, ok: bool) void { + if (!ok) self.spinner.markToolFailed(); + self.spinner.cancel(); +} + /// Called after the tool returns. /// /// - Spinner mode (TTY REPL): the running label flashes red on failure diff --git a/src/browser/tools.zig b/src/browser/tools.zig index f20003f4..6fedcf76 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -790,7 +790,12 @@ fn formatActionResult( return std.fmt.allocPrint(arena, "{s} ({s}){s}", .{ prefix, target, suffix }) catch ToolError.InternalError; } -fn appendPageContext(arena: std.mem.Allocator, body: []const u8, page: *lp.Frame) ToolError![]const u8 { +/// Finish a state-changing action: drain any queued navigation triggered by +/// the action, then tag `body` with the resulting page URL and title so the +/// caller (LLM, MCP client) can see whether the action triggered navigation. +fn finalizeAction(arena: std.mem.Allocator, session: *lp.Session, body: []const u8) ToolError![]const u8 { + try awaitQueuedNavigation(session); + const page = try requireFrame(session); const page_title = page.getTitle() catch null; return std.fmt.allocPrint(arena, "{s}. Page url: {s}, title: {s}", .{ body, page.url, page_title orelse "(none)", @@ -807,11 +812,8 @@ fn execClick(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. lp.actions.click(resolved.node, resolved.page) catch |err| return mapActionError(err); - try awaitQueuedNavigation(session); - - const page = try requireFrame(session); const body = try formatActionResult(arena, "Clicked element", args.selector, args.backendNodeId, ""); - return appendPageContext(arena, body, page); + return finalizeAction(arena, session, body); } fn execFill(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -830,7 +832,8 @@ fn execFill(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R // Show the original reference (e.g. $LP_PASSWORD) in the result, not the resolved value const suffix = std.fmt.allocPrint(arena, " with \"{s}\"", .{raw_text}) catch return ToolError.InternalError; - return formatActionResult(arena, "Filled element", args.selector, args.backendNodeId, suffix); + const body = try formatActionResult(arena, "Filled element", args.selector, args.backendNodeId, suffix); + return finalizeAction(arena, session, body); } fn execScroll(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -881,7 +884,8 @@ fn execHover(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. lp.actions.hover(resolved.node, resolved.page) catch |err| return mapActionError(err); - return formatActionResult(arena, "Hovered element", args.selector, args.backendNodeId, ""); + const body = try formatActionResult(arena, "Hovered element", args.selector, args.backendNodeId, ""); + return finalizeAction(arena, session, body); } fn execPress(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -896,12 +900,10 @@ fn execPress(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. lp.actions.press(target_node, args.key, page) catch |err| return mapActionError(err); - // Pressing Enter on a form input triggers implicit form submission. - try awaitQueuedNavigation(session); - - const current_page = try requireFrame(session); + // Pressing Enter on a form input triggers implicit form submission; + // `finalizeAction` drains the queued navigation before tagging the body. const body = std.fmt.allocPrint(arena, "Pressed key '{s}'", .{args.key}) catch return ToolError.InternalError; - return appendPageContext(arena, body, current_page); + return finalizeAction(arena, session, body); } fn execSelectOption(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -916,7 +918,8 @@ fn execSelectOption(arena: std.mem.Allocator, session: *lp.Session, registry: *C lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| return mapActionError(err); const prefix = std.fmt.allocPrint(arena, "Selected option '{s}'", .{args.value}) catch return ToolError.InternalError; - return formatActionResult(arena, prefix, args.selector, args.backendNodeId, ""); + const body = try formatActionResult(arena, prefix, args.selector, args.backendNodeId, ""); + return finalizeAction(arena, session, body); } fn execSetChecked(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -932,7 +935,8 @@ fn execSetChecked(arena: std.mem.Allocator, session: *lp.Session, registry: *CDP const state_str: []const u8 = if (args.checked) "checked" else "unchecked"; const suffix = std.fmt.allocPrint(arena, " to {s}", .{state_str}) catch return ToolError.InternalError; - return formatActionResult(arena, "Set element", args.selector, args.backendNodeId, suffix); + const body = try formatActionResult(arena, "Set element", args.selector, args.backendNodeId, suffix); + return finalizeAction(arena, session, body); } fn execFindElement(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 {