summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--agent/tools/shell.lua7
-rw-r--r--src/tui_components.zig92
2 files changed, 95 insertions, 4 deletions
diff --git a/agent/tools/shell.lua b/agent/tools/shell.lua
index 815a8b9..f9be58b 100644
--- a/agent/tools/shell.lua
+++ b/agent/tools/shell.lua
@@ -407,18 +407,21 @@ return {
end
local body = buffer_to_string()
+ -- Strip a single trailing newline so the notice (when appended) is
+ -- separated by exactly one blank line, not two.
+ body = body:gsub("\n$", "")
local parts = { header, "", body }
if overflowed then
if spill_path and not spill_error then
parts[#parts + 1] = string.format(
- "\n[truncated: kept last %d bytes; full %d-byte transcript " ..
+ "[truncated: kept last %d bytes; full %d-byte transcript " ..
"saved to %s. Use `read` with `start_line`/`end_line` " ..
"to inspect specific regions.]",
MAX_BYTES, total_written_to_spill, spill_path
)
else
parts[#parts + 1] = string.format(
- "\n[truncated: kept last %d bytes; could NOT spill to disk: %s]",
+ "[truncated: kept last %d bytes; could NOT spill to disk: %s]",
MAX_BYTES, spill_error or "unknown error"
)
end
diff --git a/src/tui_components.zig b/src/tui_components.zig
index 48f9377..a9648ee 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -196,13 +196,69 @@ fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8)
try out.append(alloc, text[line_start..]);
}
+/// Append `text` to `out` with terminal escape sequences removed. Tool output
+/// frequently carries SGR colour codes and other ANSI control sequences; the
+/// render component styles its own background, so raw escapes corrupt the box.
+/// Recognised forms:
+/// - CSI: ESC `[` ... final byte in 0x40..0x7E (e.g. SGR colours)
+/// - OSC: ESC `]` ... terminated by BEL (0x07) or ST (ESC `\`)
+/// - Other escapes: ESC, optional intermediate bytes (0x20..0x2F), final byte
+/// (e.g. charset select `ESC ( B`)
+/// A bare trailing ESC (truncated sequence) is dropped. All non-escape bytes,
+/// including newlines and UTF-8, pass through unchanged.
+fn stripAnsi(text: []const u8, out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void {
+ var i: usize = 0;
+ while (i < text.len) {
+ const c = text[i];
+ if (c != 0x1B) {
+ try out.append(alloc, c);
+ i += 1;
+ continue;
+ }
+ // ESC seen. Look at the next byte to classify the sequence.
+ if (i + 1 >= text.len) break; // bare trailing ESC: drop it.
+ const kind = text[i + 1];
+ if (kind == '[') {
+ // CSI: consume until a final byte in 0x40..0x7E (inclusive).
+ var j = i + 2;
+ while (j < text.len and !(text[j] >= 0x40 and text[j] <= 0x7E)) j += 1;
+ i = if (j < text.len) j + 1 else text.len;
+ } else if (kind == ']') {
+ // OSC: consume until BEL or ST (ESC `\`).
+ var j = i + 2;
+ while (j < text.len) {
+ if (text[j] == 0x07) {
+ j += 1;
+ break;
+ }
+ if (text[j] == 0x1B and j + 1 < text.len and text[j + 1] == '\\') {
+ j += 2;
+ break;
+ }
+ j += 1;
+ }
+ i = j;
+ } else {
+ // Other escapes (e.g. charset select `ESC ( B`): consume any
+ // intermediate bytes (0x20..0x2F) then one final byte.
+ var j = i + 1;
+ while (j < text.len and text[j] >= 0x20 and text[j] <= 0x2F) j += 1;
+ i = if (j < text.len) j + 1 else text.len;
+ }
+ }
+}
+
/// Split `buffer` on newlines into paragraphs and wrap each to `width`,
/// appending all produced lines to `out`. A trailing newline produces a final
/// empty line (so a freshly-typed "\n" shows a blank row). An empty buffer
/// produces no lines.
fn wrapBuffer(buffer: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void {
if (buffer.len == 0) return;
- var it = std.mem.splitScalar(u8, buffer, '\n');
+ // A single trailing newline is a line *terminator*, not an empty final
+ // line — strip it so `"a\nb\n"` wraps to two lines, not three.
+ const trimmed = if (buffer[buffer.len - 1] == '\n') buffer[0 .. buffer.len - 1] else buffer;
+ if (trimmed.len == 0) return;
+ var it = std.mem.splitScalar(u8, trimmed, '\n');
while (it.next()) |para| {
try wrapParagraph(para, width, out, alloc);
}
@@ -1576,7 +1632,9 @@ pub const ToolUse = struct {
pub fn setOutput(self: *ToolUse, value: []const u8) !void {
if (self.output == null) self.output = .empty;
self.output.?.clearRetainingCapacity();
- try self.output.?.appendSlice(self.alloc, value);
+ // Strip ANSI escapes once on ingest so colour codes from commands
+ // don't corrupt the component's own background styling.
+ try stripAnsi(value, &self.output.?, self.alloc);
self.cache.markDirty();
}
@@ -2518,6 +2576,36 @@ test "ToolUse: short output is shown whole even when collapsed" {
try testing.expect(seen_only and seen_two);
}
+test "ToolUse: ANSI escapes are stripped from output on ingest" {
+ var t = ToolUse.init(testing.allocator);
+ defer t.deinit();
+ try t.setName("grep");
+ try t.setInput("{}");
+ // SGR colour around "match", an OSC title, and a charset-select escape.
+ try t.setOutput("\x1b[1;31mmatch\x1b[0m here \x1b]0;title\x07\x1b(Bdone");
+ // The stored output must be free of escapes but keep the text.
+ try testing.expectEqualStrings("match here done", t.output.?.items);
+}
+
+test "stripAnsi: handles CSI, OSC, plain text, and truncated escapes" {
+ const a = testing.allocator;
+ const cases = [_]struct { in: []const u8, want: []const u8 }{
+ .{ .in = "plain", .want = "plain" },
+ .{ .in = "\x1b[31mred\x1b[0m", .want = "red" },
+ .{ .in = "a\x1b[1mb\nc", .want = "ab\nc" },
+ .{ .in = "x\x1b]0;t\x07y", .want = "xy" },
+ .{ .in = "x\x1b]0;t\x1b\\y", .want = "xy" },
+ .{ .in = "trailing\x1b", .want = "trailing" },
+ .{ .in = "\x1b(Bplain", .want = "plain" },
+ };
+ for (cases) |c| {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(a);
+ try stripAnsi(c.in, &out, a);
+ try testing.expectEqualStrings(c.want, out.items);
+ }
+}
+
test "ToolUse: collapse/expand is a length change with a cache-derived firstLineChanged" {
// Expanding/collapsing changes the rendered LINE COUNT (plan §3.3). A
// collapse toggle is a structural change (the whole output region shifts),