agent: simplify spinner and remove tool failure state

This commit is contained in:
Adrià Arrufat
2026-05-19 13:16:51 +02:00
parent e0af9c4168
commit 19e3e7b74e
3 changed files with 18 additions and 50 deletions

View File

@@ -448,7 +448,7 @@ fn runRepl(self: *Agent) void {
var arena: std.heap.ArenaAllocator = .init(self.allocator);
defer arena.deinit();
const result = self.cmd_runner.executeWithResult(arena.allocator(), cmd);
self.terminal.endTool(!result.is_error);
self.terminal.endTool();
self.cmd_runner.printResult(cmd, result);
if (self.recorder) |*r| r.record(cmd);
},
@@ -495,14 +495,14 @@ fn handleSlash(self: *Agent, body: []const u8) bool {
self.terminal.beginTool(schema.tool_name, rest);
if (self.tool_executor.call(aa, schema.tool_name, args_json)) |result| {
self.terminal.endTool(!result.is_error);
self.terminal.endTool();
if (result.is_error) {
self.terminal.printErrorFmt("{s}: {s}", .{ schema.tool_name, result.text });
} else {
self.terminal.printToolResult(schema.tool_name, result.text);
}
} else |err| {
self.terminal.endTool(false);
self.terminal.endTool();
self.terminal.printErrorFmt("{s}: {s}", .{ schema.tool_name, @errorName(err) });
}
return false;

View File

@@ -23,8 +23,7 @@ const ansi = @import("Terminal.zig").ansi;
const Spinner = @This();
const dots = [_][]const u8{ " ", ". ", ".. ", "..." };
const braille = [_][]const u8{ "", "", "", "", "", "", "", "", "", "" };
const braille = [_][]const u8{ "", "", "", "", "", "", "", "" };
const interval_ns: u64 = 100 * std.time.ns_per_ms;
/// Minimum dwell on a tool label so the user can read it. Slow tools exceed
/// this naturally; fast ones (getUrl, getCookies) get padded.
@@ -44,8 +43,6 @@ const ToolState = struct {
/// Worker should flip back to thinking once dwell elapses. Cleared by a
/// fresh `setTool` (which overrides the dwell with a new label).
dwell_pending: bool = false,
/// Render the label in red — set by `markToolFailed`, cleared by next setTool.
failed: bool = false,
/// User-typed REPL commands drop the `agent:` framing since no agent
/// is involved — it's lightpanda running the command directly.
manual: bool = false,
@@ -168,7 +165,6 @@ pub fn setTool(self: *Spinner, name: []const u8, args: []const u8) void {
@memcpy(tool.name_buf[0..tool.name_len], name[0..tool.name_len]);
tool.args_len = @min(args.len, tool.args_buf.len);
@memcpy(tool.args_buf[0..tool.args_len], args[0..tool.args_len]);
self.frame = 0;
self.state = .{ .tool = tool };
// Manual paths skip `start()`, so spawn the worker on demand to drive
// the braille animation.
@@ -177,22 +173,6 @@ pub fn setTool(self: *Spinner, name: []const u8, args: []const u8) void {
self.cv.signal();
}
/// Repaint the active tool label in red to flag a failed tool call. Visible
/// for the rest of the dwell window (`min_tool_display_ns`), then the
/// indicator returns to thinking like any other call.
pub fn markToolFailed(self: *Spinner) void {
if (!self.isEnabled()) return;
self.mu.lock();
defer self.mu.unlock();
switch (self.state) {
.tool => {
self.state.tool.failed = true;
self.renderLocked();
},
else => {},
}
}
/// Request a transition back to the cycling "thinking" state. The worker
/// honors `min_tool_display_ns` — if the current tool label has not been
/// up long enough, the flip is deferred until it has.
@@ -241,7 +221,6 @@ fn workerLoop(self: *Spinner) void {
const delta: i128 = std.time.nanoTimestamp() - self.state.tool.set_ns;
if (delta >= @as(i128, min_tool_display_ns)) {
self.state = .thinking;
self.frame = 0;
}
}
},
@@ -250,32 +229,27 @@ fn workerLoop(self: *Spinner) void {
self.renderLocked();
if (self.state == .thinking) {
self.frame = (self.frame + 1) % @as(u8, @intCast(dots.len));
} else if (std.meta.activeTag(self.state) == .tool and self.state.tool.manual) {
self.frame = (self.frame + 1) % @as(u8, @intCast(braille.len));
}
self.frame = (self.frame + 1) % @as(u8, @intCast(braille.len));
self.cv.timedWait(&self.mu, interval_ns) catch {};
}
}
fn renderLocked(self: *Spinner) void {
var buf: [frame_buf_bytes]u8 = undefined;
const glyph = braille[self.frame % braille.len];
const written = switch (self.state) {
.idle => return,
.thinking => std.fmt.bufPrint(
&buf,
"\r" ++ ansi.yellow ++ "" ++ ansi.reset ++ " " ++ ansi.dim ++ "[agent: thinking{s}]" ++ ansi.reset ++ clear_eol,
.{dots[self.frame % dots.len]},
"\r" ++ ansi.yellow ++ "{s}" ++ ansi.reset ++ " " ++ ansi.dim ++ "[agent: thinking]" ++ ansi.reset ++ clear_eol,
.{glyph},
) catch return,
.tool => |tool| blk: {
const color: []const u8 = if (tool.failed) ansi.red else if (tool.manual) ansi.yellow else ansi.green;
const glyph: []const u8 = if (tool.manual) braille[self.frame % braille.len] else "";
const prefix: []const u8 = if (tool.manual) "" else "agent: ";
break :blk std.fmt.bufPrint(
&buf,
"\r{s}{s}" ++ ansi.reset ++ " " ++ ansi.dim ++ "[{s}{s} {s}]" ++ ansi.reset ++ clear_eol,
.{ color, glyph, prefix, tool.name_buf[0..tool.name_len], tool.args_buf[0..tool.args_len] },
"\r" ++ ansi.yellow ++ "{s}" ++ ansi.reset ++ " " ++ ansi.dim ++ "[{s}{s} {s}]" ++ ansi.reset ++ clear_eol,
.{ glyph, prefix, tool.name_buf[0..tool.name_len], tool.args_buf[0..tool.args_len] },
) catch return;
},
};

View File

@@ -135,25 +135,19 @@ pub fn beginTool(self: *Terminal, 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: *Terminal, ok: bool) void {
if (!ok) self.spinner.markToolFailed();
/// Mark the end of a manual REPL tool call. Clears the running spinner; the
/// caller's `printToolResult` / `printError` lays down the colored status dot.
pub fn endTool(self: *Terminal) void {
self.spinner.cancel();
}
/// Called after the tool returns.
///
/// - Spinner mode (TTY REPL): the running label flashes red on failure
/// (handled by `markToolFailed`). At `medium`+, *also* commit a
/// `● [tool: …]` line above the spinner so the run leaves a trace.
/// - No spinner (non-TTY/non-REPL): print the same line directly,
/// gated on `medium`+. In non-TTY contexts ANSI is still emitted —
/// pipes that strip color see plain text via the bullet character.
/// Called after the tool returns. At `medium`+, commits a `● [tool: …]` line
/// above the spinner (green/red bullet for ok/fail) so the run leaves a trace.
/// In non-TTY contexts ANSI is still emitted — pipes that strip color see
/// plain text via the bullet character.
pub fn agentToolDone(self: *Terminal, name: []const u8, args: []const u8, ok: bool) void {
const spinner_on = self.spinner.isEnabled();
if (spinner_on and !ok) self.spinner.markToolFailed();
if (!atLeast(self.verbosity, .medium)) return;
const spinner_on = self.spinner.isEnabled();
if (spinner_on) {
const a = if (self.repl_arena) |*ra| ra else return;