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.
This commit is contained in:
Adrià Arrufat
2026-05-23 17:22:47 +02:00
parent 6a0801a6f6
commit 8721bfb69e
2 changed files with 65 additions and 6 deletions

View File

@@ -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];
// "<glyph> [<prefix><name> <args>]" — 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;
}

View File

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