9b54f69bee5a

fix diff ANSI: capture+strip instead of NO_COLOR — nix store diff-closures ignores NO_COLOR, so when piped, diffClosures now captures output and strips CSI sequences via stripAnsi(); tty path still streams native color — verified on kyoshi: 'nina diff | cat' emits 0 ESC bytes (was 1); build+tests green incl stripAnsi test

time
2026-06-05 02:26 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
nina finishing brief
parent
b7d3ccd72f2a
1 file src/exec.zig+37 −6
src/exec.zig+37 −6
@@ -737,13 +737,44 @@737737     defer gpa.free(a_str);738738     const b_str = try std.fmt.allocPrint(gpa, "/nix/var/nix/profiles/system-{d}-link", .{b});739739     defer gpa.free(b_str);740-    // nix store diff-closures colorizes its own output. When nina's stdout isn't a741-    // tty (piped/redirected), force NO_COLOR on the child so raw ANSI doesn't leak742-    // into the piped text. `env` carries the var through SSH routing too.743-    if (!output.colorEnabled()) {744-        return stream(io, machine, gpa, &.{ "env", "NO_COLOR=1", "nix", "store", "diff-closures", a_str, b_str });740+    // On a tty, the child's own color is fine — stream it directly.741+    if (output.colorEnabled()) {742+        return stream(io, machine, gpa, &.{ "nix", "store", "diff-closures", a_str, b_str });745743     }746-    return stream(io, machine, gpa, &.{ "nix", "store", "diff-closures", a_str, b_str });744+    // Piped/redirected: nix store diff-closures colorizes regardless of NO_COLOR,745+    // so we can't ask it to stop — capture its output and strip the ANSI ourselves.746+    const r = try capture(gpa, io, machine, &.{ "nix", "store", "diff-closures", a_str, b_str });747+    defer gpa.free(r.stdout);748+    defer gpa.free(r.stderr);749+    const cleaned = try stripAnsi(gpa, r.stdout);750+    defer gpa.free(cleaned);751+    output.raw(cleaned);752+    return r.exit_code;753+}754+755+// Strip CSI escape sequences (ESC '[' ... final-byte) from text. Used to clean a756+// child process's color when it writes ANSI unconditionally.757+fn stripAnsi(gpa: std.mem.Allocator, input: []const u8) ![]u8 {758+    var out: std.ArrayList(u8) = .empty;759+    errdefer out.deinit(gpa);760+    var i: usize = 0;761+    while (i < input.len) {762+        if (input[i] == 0x1b and i + 1 < input.len and input[i + 1] == '[') {763+            i += 2;764+            while (i < input.len and !(input[i] >= 0x40 and input[i] <= 0x7e)) : (i += 1) {}765+            if (i < input.len) i += 1; // consume the final byte766+            continue;767+        }768+        try out.append(gpa, input[i]);769+        i += 1;770+    }771+    return out.toOwnedSlice(gpa);772+}773+774+test "stripAnsi removes CSI color sequences" {775+    const cleaned = try stripAnsi(std.testing.allocator, "kepr: \x1b[31;1m13.1 KiB\x1b[0m");776+    defer std.testing.allocator.free(cleaned);777+    try std.testing.expectEqualStrings("kepr: 13.1 KiB", cleaned);747778 }748779 749780 // --- Doctor checks ---

view file