8e8ad66cbc0a

apply: live build panel — single name slot, cyan/yellow/green bar, fetch/build/activate phases, non-TTY suppressed; bump v3.2.3

time
2026-06-12 19:41 UTC · 4 months ago
machine
koh2
agent
claude-sonnet-4-6
session
apply live build panel
parent
a04f62111174
5 files VERSION+1 −1build.zig.zon+1 −1src/commands.zig+5 −1src/exec.zig+75 −0src/output.zig+181 −0
VERSION+1 −1
@@ -1 +1 @@1-3.2.21+3.2.3

view file

build.zig.zon+1 −1
@@ -9,7 +9,7 @@99     .name = .nina,1010     // This is a [Semantic Version](https://semver.org/).1111     // In a future version of Zig it will be used for package deduplication.12-    .version = "3.2.2",12+    .version = "3.2.3",1313     // Together with name, this represents a globally unique package1414     // identifier. This field is generated by the Zig toolchain when the1515     // package is first created, and then *never changes*. This allows

view file

src/commands.zig+5 −1
@@ -75,12 +75,16 @@7575     }7676 7777     output.applyStart(ctx.machine.name);78+    output.buildPanelInit();7879     const start = std.Io.Clock.awake.now(ctx.io);7980 80-    const code = exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa) catch |e| {81+    var panel = output.BuildPanel{};82+    const code = exec.nixosRebuildSwitchWithPanel(ctx.io, &ctx.machine, ctx.gpa, &panel) catch |e| {83+        output.buildPanelClear();8184         exec.markApplyFailed(ctx.io, ctx.environ);8285         return e;8386     };87+    output.buildPanelClear();8488 8589     if (code != 0) {8690         exec.markApplyFailed(ctx.io, ctx.environ);

view file

src/exec.zig+75 −0
@@ -374,6 +374,81 @@374374     return nixosRebuildRun(io, machine, gpa, "switch", flake);375375 }376376 377+// Live-streaming variant used by `apply` when stdout is a TTY. Each stderr line378+// from nixos-rebuild is fed to the build panel for real-time progress display379+// while the full stderr is also accumulated for the error translation pipeline.380+// Falls back to the silent capture path when not a TTY.381+pub fn nixosRebuildSwitchWithPanel(382+    io: std.Io,383+    machine: *const types.Machine,384+    gpa: std.mem.Allocator,385+    panel: *output.BuildPanel,386+) !u8 {387+    const flake = useFlakeFlag(gpa, io, machine);388+    if (flake) {389+        output.printSubstep("nixos-rebuild switch --flake {s}", .{machine.config_path});390+    } else {391+        output.printSubstep("nixos-rebuild switch", .{});392+    }393+    if (!output.colorEnabled()) return nixosRebuildRun(io, machine, gpa, "switch", flake);394+    return nixosRebuildRunStreamed(io, machine, gpa, "switch", flake, panel);395+}396+397+fn nixosRebuildRunStreamed(398+    io: std.Io,399+    machine: *const types.Machine,400+    gpa: std.mem.Allocator,401+    verb: []const u8,402+    flake: bool,403+    panel: *output.BuildPanel,404+) !u8 {405+    const argv: []const []const u8 = if (flake)406+        &.{ "sudo", "nixos-rebuild", verb, "--flake", machine.config_path }407+    else408+        &.{ "sudo", "nixos-rebuild", verb };409+410+    output.flush();411+    const built_argv = try buildArgv(gpa, machine, argv);412+    defer if (!machine.local) gpa.free(built_argv);413+414+    var child = std.process.spawn(io, .{415+        .argv = built_argv,416+        .stdin = .ignore,417+        .stdout = .ignore,418+        .stderr = .pipe,419+    }) catch |e| {420+        errors.error_info.setMessage("failed to spawn nixos-rebuild", .{});421+        return e;422+    };423+424+    var accumulated: std.ArrayList(u8) = .empty;425+    defer accumulated.deinit(gpa);426+427+    if (child.stderr) |stderr_file| {428+        var reader_buf: [8192]u8 = undefined;429+        var fr = std.Io.File.Reader.initStreaming(stderr_file, io, &reader_buf);430+        while (true) {431+            const maybe = fr.interface.takeDelimiter('\n') catch break;432+            const raw = maybe orelse break;433+            accumulated.appendSlice(gpa, raw) catch {};434+            const line = std.mem.trimEnd(u8, raw, "\r\n");435+            panel.update(line);436+            panel.render();437+        }438+    }439+440+    errors.setBuildStderr(accumulated.items);441+442+    const term = child.wait(io) catch |e| {443+        errors.error_info.setMessage("nixos-rebuild wait failed", .{});444+        return e;445+    };446+    return switch (term) {447+        .exited => |code| code,448+        else => 1,449+    };450+}451+377452 pub fn nixosRebuildDryActivate(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {378453     return nixosRebuildRun(io, machine, gpa, "dry-activate", useFlakeFlag(gpa, io, machine));379454 }

view file

src/output.zig+181 −0
@@ -58,6 +58,187 @@5858 var color_enabled: bool = true;5959 var teach_enabled: bool = false;6060 61+// --- Build panel ---62+63+pub const BuildPanel = struct {64+    total:      u32 = 0,65+    completed:  u32 = 0,66+    total_mb:   f32 = 0,67+    active:     [32]u8 = [_]u8{0} ** 32,68+    active_len: usize = 0,69+    mode:       enum { fetching, building, activating } = .fetching,70+    pulse_pos:  u8 = 0,71+72+    const BAR_WIDTH = 28;73+74+    pub fn update(self: *BuildPanel, line: []const u8) void {75+        // "these N paths will be fetched (X MiB download, Y MiB unpacked)"76+        if (std.mem.indexOf(u8, line, "paths will be fetched") != null) {77+            if (parseAfterWord(line, "these ")) |n_str| {78+                if (std.fmt.parseInt(u32, n_str, 10) catch null) |n| self.total = n;79+            }80+            // "(X MiB download..."81+            if (std.mem.indexOfScalar(u8, line, '(')) |pi| {82+                const after = std.mem.trim(u8, line[pi + 1 ..], " \t");83+                if (indexOfSpace(after)) |sp| {84+                    if (std.fmt.parseFloat(f32, after[0..sp]) catch null) |mb| self.total_mb = mb;85+                }86+            }87+            return;88+        }89+90+        // "fetching path '/nix/store/...'"91+        if (std.mem.indexOf(u8, line, "fetching path '") != null) {92+            self.completed +|= 1;93+            self.mode = .fetching;94+            self.setActiveName(line, false);95+            return;96+        }97+98+        // "building '/nix/store/...-name.drv'"99+        if (std.mem.indexOf(u8, line, "building '") != null and100+            std.mem.indexOf(u8, line, ".drv") != null)101+        {102+            self.mode = .building;103+            self.setActiveName(line, true);104+            return;105+        }106+107+        // "activating the configuration..."108+        if (std.mem.indexOf(u8, line, "activating the configuration") != null or109+            std.mem.indexOf(u8, line, "activating configuration") != null)110+        {111+            self.mode = .activating;112+            if (self.total > 0) self.completed = self.total;113+            const msg = "activating configuration...";114+            const copy_len = @min(msg.len, self.active.len);115+            @memcpy(self.active[0..copy_len], msg[0..copy_len]);116+            self.active_len = copy_len;117+            return;118+        }119+    }120+121+    pub fn render(self: *BuildPanel) void {122+        if (!color_enabled) return;123+124+        // Up 2 lines to the name line (blank line is below header, name is next)125+        p("\x1b[2A", .{});126+127+        // Name line128+        p("\r\x1b[K", .{});129+        const name = self.active[0..self.active_len];130+        switch (self.mode) {131+            .fetching => p("   {s}fetching{s}  {s}{s}{s}", .{ c(DIM), c(RESET), c(CYAN), name, c(RESET) }),132+            .building => p("   {s}building{s}  {s}{s}{s}", .{ c(DIM), c(RESET), c(YELLOW), name, c(RESET) }),133+            .activating => p("   {s}{s}{s}", .{ c(GREEN), name, c(RESET) }),134+        }135+        p("\n", .{});136+137+        // Bar line138+        p("\r\x1b[K", .{});139+        if (self.total == 0) {140+            self.drawPulse();141+        } else {142+            self.drawCountedBar();143+        }144+        p("\n", .{});145+146+        flush();147+    }148+149+    fn drawCountedBar(self: *BuildPanel) void {150+        const filled: u32 = if (self.total > 0)151+            @min(BAR_WIDTH, (self.completed * BAR_WIDTH) / self.total)152+        else153+            0;154+        const empty: u32 = BAR_WIDTH - filled;155+156+        const blk_color = switch (self.mode) {157+            .fetching => CYAN,158+            .building => YELLOW,159+            .activating => GREEN,160+        };161+162+        p("   ", .{});163+        if (filled > 0) {164+            p("{s}", .{c(blk_color)});165+            for (0..filled) |_| p("█", .{});166+            p("{s}", .{c(RESET)});167+        }168+        if (empty > 0) {169+            p("{s}", .{c(DIM)});170+            for (0..empty) |_| p("░", .{});171+            p("{s}", .{c(RESET)});172+        }173+        p("   {s}{d} / {d} paths{s}", .{ c(DIM), self.completed, self.total, c(RESET) });174+        if (self.total_mb > 0) {175+            const done_mb = self.total_mb * @as(f32, @floatFromInt(self.completed)) /176+                @as(f32, @floatFromInt(@max(self.total, 1)));177+            p("   {d:.1} MB / {d:.1} MB", .{ done_mb, self.total_mb });178+        }179+    }180+181+    fn drawPulse(self: *BuildPanel) void {182+        const PULSE_SIZE: u8 = 4;183+        const blk_color = switch (self.mode) {184+            .fetching => CYAN,185+            .building => YELLOW,186+            .activating => GREEN,187+        };188+        p("   ", .{});189+        for (0..BAR_WIDTH) |i| {190+            const pos: u8 = @intCast(i);191+            const in_pulse = pos >= self.pulse_pos and pos < self.pulse_pos + PULSE_SIZE;192+            if (in_pulse) {193+                p("{s}█{s}", .{ c(blk_color), c(RESET) });194+            } else {195+                p("{s}░{s}", .{ c(DIM), c(RESET) });196+            }197+        }198+        self.pulse_pos = (self.pulse_pos + 1) % BAR_WIDTH;199+    }200+201+    fn setActiveName(self: *BuildPanel, line: []const u8, is_drv: bool) void {202+        const store = "/nix/store/";203+        const idx = std.mem.indexOf(u8, line, store) orelse return;204+        const after = line[idx + store.len ..];205+        if (after.len < 33) return;206+        var name = after[33..]; // skip 32-char hash + dash207+        if (is_drv) {208+            if (std.mem.endsWith(u8, name, ".drv")) name = name[0 .. name.len - 4];209+        }210+        // Trim trailing quote / whitespace211+        name = std.mem.trimEnd(u8, name, "' \t\r\n");212+        const copy_len = @min(name.len, self.active.len);213+        @memcpy(self.active[0..copy_len], name[0..copy_len]);214+        self.active_len = copy_len;215+    }216+};217+218+fn parseAfterWord(line: []const u8, word: []const u8) ?[]const u8 {219+    const idx = std.mem.indexOf(u8, line, word) orelse return null;220+    const after = line[idx + word.len ..];221+    const end = indexOfSpace(after) orelse return null;222+    return after[0..end];223+}224+225+fn indexOfSpace(s: []const u8) ?usize {226+    for (s, 0..) |b, i| if (b == ' ' or b == '\t') return i;227+    return null;228+}229+230+pub fn buildPanelInit() void {231+    if (!color_enabled) return;232+    p("\n\n\n", .{});233+    flush();234+}235+236+pub fn buildPanelClear() void {237+    if (!color_enabled) return;238+    p("\n", .{});239+    flush();240+}241+61242 pub fn init(writer: *Io.Writer, io: std.Io, environ: *const std.process.Environ.Map, color_config: bool) void {62243     _w = writer;63244     color_enabled = color_config and

view file