From 9b7225b5804ba5ab9a424bf8eeefed0cc9f04a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 18 Sep 2026 00:44:49 +0200 Subject: [PATCH] agent: don't drop REPL tool lines or cut result text mid-codepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a REPL whose stderr isn't a tty the spinner is disabled, so `agentToolDone`'s `emitAbove` returned false and the `● [tool: …]` line was discarded. `printToolOutcome` already fell back to a raw stderr write in that case; share that fallback through `emitStderr`. The non-REPL result line sliced `text` at a byte offset, which could split a multi-byte codepoint and emit garbage. Truncate on a UTF-8 boundary instead. --- src/agent/Terminal.zig | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index e07a89b55..925cd0a99 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -18,6 +18,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const string = @import("../string.zig"); const Config = lp.Config; const Schema = lp.Schema; const SlashCommand = @import("SlashCommand.zig"); @@ -117,7 +118,7 @@ pub fn agentToolDone(self: *Terminal, name: []const u8, args: []const u8, ok: bo const a = if (self.repl_arena) |*ra| ra else return; defer _ = a.reset(.retain_capacity); const bytes = formatBulletLine(a.allocator(), name, args, ok) catch return; - _ = self.spinner.emitAbove(bytes); + self.emitStderr(bytes); return; } if (self.stderr_is_tty) { @@ -131,6 +132,13 @@ pub fn agentToolDone(self: *Terminal, name: []const u8, args: []const u8, ok: bo } } +/// Commit a finished line above the spinner, or straight to stderr when the +/// spinner isn't running (non-tty REPL) so the line isn't silently dropped. +fn emitStderr(self: *Terminal, bytes: []const u8) void { + if (self.spinner.emitAbove(bytes)) return; + _ = std.c.write(std.posix.STDERR_FILENO, bytes.ptr, bytes.len); +} + fn formatBulletLine(arena: std.mem.Allocator, name: []const u8, args: []const u8, ok: bool) ![]const u8 { var aw: std.Io.Writer.Allocating = .init(arena); const w = &aw.writer; @@ -286,12 +294,10 @@ pub fn printToolOutcome(self: *Terminal, name: []const u8, text: []const u8, is_ if (self.repl_arena) |*a| { defer _ = a.reset(.retain_capacity); const bytes = formatReplOutcome(a.allocator(), text, is_error) catch return; - if (self.spinner.emitAbove(bytes)) return; - _ = std.c.write(std.posix.STDERR_FILENO, (bytes).ptr, (bytes).len); - return; + return self.emitStderr(bytes); } if (!is_error and !self.verbosity.atLeast(.medium)) return; - const truncated = text[0..@min(text.len, max_result_display_len)]; + const truncated = string.truncateUtf8(text, max_result_display_len); const ellipsis: []const u8 = if (text.len > max_result_display_len) "..." else ""; const color: []const u8 = if (is_error) ansi.red else ansi.green; std.debug.print("{s}{s}[result: {s}]{s} {s}{s}\n", .{ ansi.dim, color, name, ansi.reset, truncated, ellipsis });