refactor: extract JSON formatting and timeout helpers

This commit is contained in:
Adrià Arrufat
2026-05-30 23:48:50 +02:00
parent 53ba47cbec
commit 88fdeeade8
3 changed files with 32 additions and 51 deletions

View File

@@ -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 {

View File

@@ -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();
}

View File

@@ -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));
}