Progress Gauge in Header Divider — Design
Date: 2026-06-01
Status: Approved (brainstorm complete); implementation plan to follow
Scope: Relocate the boot/shutdown progress indicator from the last row of the screen into the existing horizontal divider in the boot header. Eliminates a long-standing "progress bar disappears and reappears" artifact and reclaims two rows of vertical space for the rolling tape. Symmetric treatment for shutdown.
1. Problem
The boot screen rendered by render_boot_screen() in src/ui.reef ends with a horizontal divider plus a separate progress bar row, occupying the bottom two rows of the screen. The progress bar is the very last row — the file's own comment at src/ui.reef:517-519 notes that the layout fits exactly g_screen_rows and cannot afford even a trailing newline.
Between zyginit's \e[2J\e[H redraws, any other writer to the console — a kernel printk, a daemon's startup stderr, a not-yet-suppressed driver message — scrolls the screen up. The last row is the first thing to scroll off. The user sees the progress bar disappear, then reappear on the next redraw, then disappear again. The artifact is endemic to placing dynamic content on the last row of a shared console.
2. Goals
- Stability under concurrent writers. Move the progress indicator out of the most-volatile screen row.
- No layout twitch at boot completion. The transition from live gauge to final boot-time label must not shift any surrounding glyph.
- Reclaim vertical space. Eliminate both the bottom cosmetic divider and the bottom progress row — give those rows to the rolling tape.
- Symmetric shutdown. Apply the same treatment to the shutdown screen; same bug exists there.
- No regressions in plain or ASCII mode.
MODE_PLAINcontinues to emit line-by-line events;MODE_RICH_ASCIIcontinues to use only ASCII characters.
3. Non-goals
- No new TOML schema fields. This is purely a console-rendering change.
- No
zygctlprotocol changes.zygctl statusis rendered bysrc/ui_render.reefand is not touched. - No palette, sigil, or glyph changes. The visual pass shipped in 0.1.3 stays as-is; only layout moves.
- No animation changes. Spinner cadence, tape-row behavior, and tier-progression logic are unchanged.
- No supervision or dependency-graph changes. Pure UI work.
4. Visual design
4.1 Before
[Z] zyginit 0.2.2 · multi-user elapsed 2.4s
20 services · tier 3 · 8 done · 0 failed · 0 skipped
failed: none
───────────────────────────────────────── (top divider — cosmetic)
[tape row 1]
…
[tape row N]
───────────────────────────────────────── (bottom divider — cosmetic)
████░░░░░░░░░░░░░ 8/20 31% (progress bar — last row, scrolls off)
4.2 After — boot in progress
[Z] zyginit 0.2.2 · multi-user elapsed 2.4s
20 services · tier 3 · 8 done · 0 failed · 0 skipped
failed: none
─────[████████░░░░░░░░░░ 31%]─────────── (gauge inside divider)
[tape row 1]
…
[tape row N+2] (+2 rows reclaimed)
4.3 After — boot complete
The existing ui_boot_complete() at src/ui.reef:708 clears the screen and paints a separate "final card" (sigil, version, boot Xs, online/failed/skipped summary, optional failure hint, slowest-services list). The final card is unchanged by this work. The new divider gauge therefore runs only during the live boot screen; the boot post-label ([ boot 10.0s ]) is rendered in code but is only momentarily visible — within one frame, the final card replaces the boot screen.
The elapsed Xs field on header line 1 is dropped from the boot screen once g_done + g_skipped + g_failed_count == g_num_svc (i.e., the boot has visually completed even if the final-card transition hasn't fired yet). This avoids showing a stale elapsed 9.8s while the gauge reads 100% in the brief window before the final card.
4.4 Shutdown
Symmetric with boot. ui_shutdown_complete() at src/ui.reef:818 already paints a shutdown final card after the last service stops (sigil, reboot X.Ys, "invoking uadmin..." line). The shutdown final card is unchanged. The new divider gauge runs only during the live shutdown screen as services stop:
─────[█████████████░░░░░░░ 65%]────────── (during stop sequence)
Header content (status lines, failure summary) above the divider is unchanged.
5. Rendering rules
A new function fmt_divider_gauge(done: int, total: int): string replaces:
- The
make_divider()call at the end offmt_header()(the top divider). - The bottom divider line in
render_boot_screen(). - The
fmt_progress_bar()line inrender_boot_screen().
Width: identical to today's make_divider() — g_line_width - 16 cells of total divider width, with the leading two-space indent preserved. The brackets plus their inner content consume 28 cells ([ + 26 inner + ]); the remaining g_line_width - 44 cells are split equally between left and right padding (odd cell goes right).
Narrow-terminal fallback. If g_line_width < 46 (i.e., total_width < 30) the bracket pair plus at least one flanking dash on each side won't fit. In that case fmt_divider_gauge() degrades to a label-only divider — no bracket pair, just <lpad>── 31% ──<rpad>. This path is exercised only on artificially small consoles; the Hammerhead framebuffer and standard serial console widths (80+) always hit the full layout.
The layout inside the divider (full path) is:
<lpad>[<content>]<rpad>
Where:
- Brackets are literal
[and], painted with theaccentcolor. - lpad / rpad are
─chars inMODE_RICH,-chars inMODE_RICH_ASCII, paintedaccent. Padding is distributed equally on both sides; if total padding is odd, the extra cell goes to the right side. - content has a fixed inner width so the bracket positions never move between live and post-complete renders. Inner width = bar(20) + two-space gap + 4-char
NNN%field = 26 cells.
5.1 Live content
<bar> <pct>%
- bar: 20 cells. In
MODE_RICH:█(accent) for filled,░(mute) for empty. InMODE_RICH_ASCII:#for filled, space for empty. Filled count =(done * 20) / total, clamped to[0, 20]. - pct: integer
(done * 100) / total, clamped to[0, 100], right-aligned in a 3-cell field, followed by%. So" 3%"," 31%","100%"all consume 4 cells.
5.2 Plain mode
MODE_PLAIN continues to emit one line per event via fmt_plain_event() and ignores the divider gauge entirely. fmt_divider_gauge() returns "" in plain mode, same as fmt_progress_bar() does today.
6. State changes
None. Both ui_boot_complete() (src/ui.reef:708) and ui_shutdown_complete() (src/ui.reef:818) already exist and paint final cards that replace the live screen. The new divider gauge is purely a transform on existing counters (g_done, g_skipped, g_num_svc), so no new g_* flags are required.
Header behavior:
fmt_header()drops theelapsed Xsfield from line 1 wheng_done + g_skipped + g_failed_count >= g_num_svcduring the boot phase. This is a pure boolean check on existing counters; no new flag. During shutdown the elapsed field is left as-is (the shutdown final card carries the timer once complete).
7. Files touched
-
src/ui.reef— only file touched.- New:
fmt_divider_gauge(done, total)— main rendering function. Includes the narrow-terminal fallback in §5. - Update:
fmt_header()— conditionalelapsed Xs(suppressed wheng_done + g_skipped + g_failed_count >= g_num_svcand phase is boot); divider line now callsfmt_divider_gauge(g_done + g_skipped, g_num_svc). - Update:
render_boot_screen()atsrc/ui.reef:499— remove the bottom divider concatenation and thefmt_progress_bar()call. Trailing-newline accounting recomputed so total height still equalsg_screen_rows. - Update: tape height calculation at
src/ui.reef:682—g_tape_height = g_screen_rows - 6becomesg_screen_rows - 4(header is 4 rows including the gauge divider; no trailing rows). - Update: scenario harness in
ui_demo()— the existingprogress_rich,progress_ascii,progress_fullscenarios at lines 950-963 currently callfmt_progress_bar(); convert them to exercisefmt_divider_gauge()instead. Add freshgauge_full_screen_bootscenario for full-layout visual check. - Delete:
fmt_progress_bar()(lines 393-439),PROGRESS_WIDTH()(line 391). No remaining callers after the harness conversion.
- New:
-
src/main.reef— no changes.ui_boot_complete()andui_shutdown_complete()already wired. -
src/shutdown.reef— no changes. -
src/ui_render.reef— no changes.zygctl statusrendering is independent.
8. Backwards compatibility
MODE_PLAINoutput — unchanged. Same line-per-event format consumers parse today.MODE_RICH_ASCIIoutput — gauge characters (#, space) and divider characters (-) are pure ASCII. No new Unicode dependencies.MODE_RICHoutput — gauge characters (█,░) and divider character (─) were already in use in the prior layout. No new code points.- Screen-height math — assumes
g_screen_rows >= 5(1 header line + 3 status lines + at least 1 tape row). Already an existing assumption; no change. - External consumers — none.
zygctl status, scripted log parsers, and the syslog stream all readMODE_PLAINor daemon log output, not the framebuffer rendering.
9. Testing
Manual on hh-prototest (the Hammerhead VM at 192.168.122.50):
- Cold boot. Confirm gauge fills as tiers progress; confirm post-boot label
boot Xsappears after the last tier; confirmelapsed Xsdisappears from header line 1. - Boot under noise. Trigger a kernel printk during boot (e.g. a
dladmconfigure with verbose driver). Confirm gauge stays visible across the scroll. - Shutdown.
zygctl halt. Confirm gauge inverse-fills; confirmdown Xslabel appears before reboot. - Single-user boot.
boot -s. Confirm gauge still renders correctly in the smaller service set. - Plain mode.
ZYGINIT_NO_UI=1,NO_COLOR=1, non-TTY redirect, or running non-PID-1. Confirm line-per-event output is unchanged. - ASCII mode.
TERM=dumb(or any non-color-capable terminal that doesn't trip the plain-mode fallback). Confirm#/ space gauge renders inside the divider.
No unit tests added — the boot UI has historically been validated visually on the test VM. Existing integration tests in tests/integration/ continue to cover supervisor behavior independently.
10. Open questions
None. All decisions made during brainstorming:
- Gauge style: block fill (
█/░in rich,#/space in ASCII) — matches the prior progress bar's character set. elapsed Xson header line 1: shown only while boot is in progress; dropped once all services have resolved (done/failed/skipped sum reaches total).- Boot completion: existing
ui_boot_complete()final card kept as-is. The new gauge runs only during the live boot screen. - Shutdown: same treatment as boot. Existing
ui_shutdown_complete()final card kept as-is. Gauge fills 0→100% as services stop, then the final card replaces the screen.