From 88fdeeade8b7bc55e18d73506e2f92df290fd78d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 30 May 2026 23:48:50 +0200 Subject: [PATCH] refactor: extract JSON formatting and timeout helpers --- src/agent/Agent.zig | 22 +++++---------------- src/agent/Terminal.zig | 43 ++++++++++++++++++----------------------- src/browser/actions.zig | 18 ++++++++--------- 3 files changed, 32 insertions(+), 51 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 0255e924..2d2a9a97 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -790,9 +790,7 @@ fn printHelpSection(term: *Terminal, header: []const u8, rows: []SlashCommand.He if (rows.len == 0) return; std.sort.pdq(SlashCommand.Help, rows, {}, helpLessThan); term.printInfo("{s}{s}{s}", .{ Terminal.ansi.bold, header, Terminal.ansi.reset }); - for (rows) |r| term.printInfo(" {s}{s}/{s}{s} — {s}", .{ - Terminal.ansi.bold, Terminal.ansi.cyan, r.name, Terminal.ansi.reset, r.description, - }); + for (rows) |r| term.printInfo(" " ++ Terminal.highlightCmd("/{s}") ++ " — {s}", .{ r.name, r.description }); } fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) void { @@ -915,21 +913,11 @@ fn printCommandResult(self: *Agent, cmd: Command, result: browser_tools.ToolResu self.terminal.printToolOutcome(tc.name(), result.text, result.is_error); } -// REPL-only: re-indent JSON output for the terminal. MCP keeps renderJson's -// compact form. Non-JSON output (markdown, tree, urls) prints unchanged. +// Re-indent JSON for the terminal; MCP keeps renderJson's compact form. fn printData(self: *Agent, text: []const u8) void { - const trimmed = std.mem.trimLeft(u8, text, &std.ascii.whitespace); - if (trimmed.len > 0 and (trimmed[0] == '{' or trimmed[0] == '[')) { - if (std.json.parseFromSlice(std.json.Value, self.allocator, text, .{})) |parsed| { - defer parsed.deinit(); - if (std.json.Stringify.valueAlloc(self.allocator, parsed.value, .{ .whitespace = .indent_2 })) |pretty| { - defer self.allocator.free(pretty); - self.terminal.printAssistant(pretty); - return; - } else |_| {} - } else |_| {} - } - self.terminal.printAssistant(text); + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + self.terminal.printAssistant(Terminal.reindentJson(arena.allocator(), text) orelse text); } fn runScript(self: *Agent, path: []const u8) bool { diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 3af5dbff..760b6ca5 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -49,8 +49,7 @@ 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. +/// Bold-cyan command styling, shared with the `/help` listing. pub fn highlightCmd(comptime fragment: []const u8) []const u8 { return ansi.bold ++ ansi.cyan ++ fragment ++ ansi.reset; } @@ -581,8 +580,7 @@ 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. +/// Closest command name within two edits, or null — for "did you mean?" on typos. pub fn closestCommand(name: []const u8) ?[]const u8 { var best: ?[]const u8 = null; var best_dist: usize = std.math.maxInt(usize); @@ -596,9 +594,8 @@ pub fn closestCommand(name: []const u8) ?[]const u8 { 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). +/// Case-insensitive Levenshtein distance. Returns `maxInt` for inputs longer +/// than 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); @@ -817,6 +814,18 @@ pub fn printToolOutcome(self: *Terminal, name: []const u8, text: []const u8, is_ std.debug.print("{s}{s}[result: {s}]{s} {s}{s}\n", .{ ansi.dim, color, name, ansi.reset, truncated, ellipsis }); } +/// Re-indents `text` as two-space JSON, or null when it isn't a JSON object/array. +/// The `{`/`[` sniff skips the parse for the common plain-text case — `text` may +/// be up to 1 MiB. +pub fn reindentJson(arena: std.mem.Allocator, text: []const u8) ?[]const u8 { + const trimmed = std.mem.trimLeft(u8, text, " \t\r\n"); + if (trimmed.len == 0 or (trimmed[0] != '{' and trimmed[0] != '[')) return null; + const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, text, .{}) catch return null; + var aw: std.Io.Writer.Allocating = .init(arena); + std.json.Stringify.value(parsed, .{ .whitespace = .indent_2 }, &aw.writer) catch return null; + return aw.written(); +} + /// REPL outcome line: colored ● marker followed by the body, pretty-printed /// if JSON. Builds the entire payload in the arena so callers can route it /// past the spinner (`emitAbove`) without interleaving with frame writes. @@ -824,25 +833,11 @@ fn formatReplOutcome(arena: std.mem.Allocator, text: []const u8, is_error: bool) var aw: std.Io.Writer.Allocating = .init(arena); const w = &aw.writer; - // Most tool results are plain text (markdown, URLs, action confirmations). - // Skip the JSON parse + Value tree allocation unless the payload could - // plausibly be JSON — `text` may be up to 1 MiB. - const trimmed = std.mem.trimLeft(u8, text, " \t\r\n"); - const looks_json = trimmed.len > 0 and (trimmed[0] == '{' or trimmed[0] == '['); - const parsed: ?std.json.Value = if (looks_json) - std.json.parseFromSliceLeaky(std.json.Value, arena, text, .{}) catch null - else - null; - const sep: []const u8 = if (parsed != null) "\n" else " "; + const pretty = reindentJson(arena, text); + const sep: []const u8 = if (pretty != null) "\n" else " "; const color: []const u8 = if (is_error) ansi.red else ansi.green; try w.print("{s}●{s}{s}", .{ color, ansi.reset, sep }); - if (parsed) |v| { - std.json.Stringify.value(v, .{ .whitespace = .indent_2 }, w) catch { - try w.writeAll(text); - }; - } else { - try w.writeAll(text); - } + try w.writeAll(pretty orelse text); try w.writeByte('\n'); return aw.written(); } diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 18410f96..d32ee6d9 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -266,16 +266,18 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, frame: *Frame) !void { } } +// Floored to 1 so timeout_ms=0 still gets one check instead of failing outright. +fn remainingMs(timeout_ms: u32, timer: *std.time.Timer) u32 { + const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); + return @max(1, timeout_ms -| elapsed); +} + pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, session: *Session) !*DOMNode { var timer = try std.time.Timer.start(); var runner = try session.runner(.{}); try runner.wait(.{ .ms = timeout_ms, .until = .load }); - const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); - // floor to 1 so timeout=0 still gets one check - const remaining = @max(1, timeout_ms -| elapsed); - - const el = try runner.waitForSelector(selector, remaining); + const el = try runner.waitForSelector(selector, remainingMs(timeout_ms, &timer)); return el.asNode(); } @@ -284,9 +286,5 @@ pub fn waitForScript(script: [:0]const u8, timeout_ms: u32, session: *Session) ! var runner = try session.runner(.{}); try runner.wait(.{ .ms = timeout_ms, .until = .load }); - const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); - // floor to 1 so timeout=0 still gets one check - const remaining = @max(1, timeout_ms -| elapsed); - - return runner.waitForScript(script, remaining); + return runner.waitForScript(script, remainingMs(timeout_ms, &timer)); }