From 8721bfb69ea894e8444f8b1001d4c996aa206f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 23 May 2026 17:22:47 +0200 Subject: [PATCH] Spinner: prevent line wrapping and multi-line breaks Strips control characters from tool arguments to avoid breaking the carriage-return redraw. Queries the terminal width and truncates arguments to fit on a single row, appending an ellipsis if needed. --- src/agent/Spinner.zig | 56 +++++++++++++++++++++++++++++++++++++----- src/agent/Terminal.zig | 15 +++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/agent/Spinner.zig b/src/agent/Spinner.zig index f73fed5d..24d80df4 100644 --- a/src/agent/Spinner.zig +++ b/src/agent/Spinner.zig @@ -19,7 +19,8 @@ const std = @import("std"); const lp = @import("lightpanda"); const log = lp.log; -const ansi = @import("Terminal.zig").ansi; +const Terminal = @import("Terminal.zig"); +const ansi = Terminal.ansi; const Spinner = @This(); @@ -30,8 +31,14 @@ const interval_ns: u64 = 100 * std.time.ns_per_ms; const min_tool_display_ns: u64 = 1500 * std.time.ns_per_ms; const clear_eol = ansi.clear_eol; -const max_args_bytes: usize = 100; -const frame_buf_bytes: usize = 256; +const max_args_bytes: usize = 256; +const frame_buf_bytes: usize = 512; +/// Visual ceiling on the args slice. Combined with the terminal-width cap so +/// the spinner line stays single-row even when the script body is huge. +const max_args_cells: usize = 70; +/// UTF-8 horizontal ellipsis ("…") — 3 bytes, 1 visual cell. +const ellipsis = "\xe2\x80\xa6"; +const ellipsis_cells: usize = 1; const ToolState = struct { name_buf: [64]u8 = undefined, @@ -163,8 +170,13 @@ pub fn setTool(self: *Spinner, name: []const u8, args: []const u8) void { var tool: ToolState = .{ .set_ns = std.time.nanoTimestamp(), .manual = manual }; tool.name_len = @min(name.len, tool.name_buf.len); @memcpy(tool.name_buf[0..tool.name_len], name[0..tool.name_len]); + // Strip control chars: a literal `\n` in args (e.g. /eval """…""" bodies) + // breaks the spinner's `\r`-based redraw — the cursor only rewinds to the + // start of the last line, leaving prior frames stuck on screen. tool.args_len = @min(args.len, tool.args_buf.len); - @memcpy(tool.args_buf[0..tool.args_len], args[0..tool.args_len]); + for (args[0..tool.args_len], 0..) |ch, i| { + tool.args_buf[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch; + } self.state = .{ .tool = tool }; // Manual paths skip `start()`, so spawn the worker on demand to drive // the braille animation. @@ -246,10 +258,26 @@ fn renderLocked(self: *Spinner) void { ) catch return, .tool => |tool| blk: { const prefix: []const u8 = if (tool.manual) "" else "agent: "; + const name = tool.name_buf[0..tool.name_len]; + const all_args = tool.args_buf[0..tool.args_len]; + // " [ ]" — 5 visible cells of fixed + // decoration (glyph, two spaces, `[`, `]`) around prefix+name+args. + // `\r` and ANSI escapes are zero-width, so they don't count toward + // terminal wrap. + const decoration_cells: usize = 5 + prefix.len + name.len; + const cols: usize = Terminal.columns() orelse 80; + // Reserve one extra cell so the line is strictly less than `cols` — + // terminals with auto-wrap (DECAWM) advance past a row that exactly + // fills the width. + const reserved = decoration_cells + ellipsis_cells + 1; + const room: usize = if (cols > reserved) cols - reserved else 0; + const cap = @min(max_args_cells, room); + const cut = truncToCells(all_args, cap); + const suffix: []const u8 = if (cut < all_args.len) ellipsis else ""; break :blk std.fmt.bufPrint( &buf, - "\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] }, + "\r" ++ ansi.yellow ++ "{s}" ++ ansi.reset ++ " " ++ ansi.dim ++ "[{s}{s} {s}{s}]" ++ ansi.reset ++ clear_eol, + .{ glyph, prefix, name, all_args[0..cut], suffix }, ) catch return; }, }; @@ -258,3 +286,19 @@ fn renderLocked(self: *Spinner) void { self.last_render_len = written.len; _ = std.posix.write(std.posix.STDERR_FILENO, written) catch {}; } + +/// Returns the byte length of `bytes` that fits in `max_cells` cells, +/// rounded down to a whole UTF-8 codepoint. Multi-cell glyphs (CJK, +/// wide emoji) are counted as 1 — args are typically ASCII so the +/// approximation is good enough. +fn truncToCells(bytes: []const u8, max_cells: usize) usize { + var cells: usize = 0; + var i: usize = 0; + while (i < bytes.len and cells < max_cells) { + const seq_len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1; + if (i + seq_len > bytes.len) break; + i += seq_len; + cells += 1; + } + return i; +} diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index a9cb559a..7828c71e 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -649,6 +649,21 @@ pub fn interactiveTty() bool { return std.posix.isatty(std.posix.STDIN_FILENO) and std.posix.isatty(std.posix.STDERR_FILENO); } +/// Current terminal width in columns, queried via TIOCGWINSZ on stderr. +/// Null when stderr isn't a tty, the ioctl fails, or the kernel reports 0 +/// (some pseudo-ttys leave the field unset). Cheap enough to call per +/// render frame — picks up resizes without SIGWINCH plumbing. +pub fn columns() ?u16 { + var ws: std.posix.winsize = undefined; + // bitcast via c_uint: on archs where `_IOR` sets the direction bit + // (MIPS/PPC/SPARC), `IOCGWINSZ` exceeds i32 range — a plain @intCast + // panics there. The bitcast preserves the bit pattern. + const req: c_int = @bitCast(@as(c_uint, std.posix.T.IOCGWINSZ)); + const rc = std.c.ioctl(std.posix.STDERR_FILENO, req, &ws); + if (rc != 0 or ws.col == 0) return null; + return ws.col; +} + /// Numbered TTY picker. `default` (if set) marks that row "(default)" and /// makes Enter return that index. Errors with NoChoice after 3 invalid /// attempts.