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
↓ nina-9b54f69b.tar.gzatlas at this save
files changed1 file
src/exec.zig+37 −6src/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 ---