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
↓ nina-8e8ad66c.tar.gzatlas at this save
files changed5 files
VERSION+1 −1build.zig.zon+1 −1src/commands.zig+5 −1src/exec.zig+75 −0src/output.zig+181 −0build.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 allowssrc/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);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 }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