I Gave My Terminal an Identity in Under 5ms

We have all seen them in screenshots, the ubiquitous Linux flavor symbol and a bunch of specs with two rows of colored squares. The standard fare when showing off one’s DM or WM rice, or super complicated build/install/[insert difficult linux task here]. So much so that, dare I say it, it’s become cliche. Every terminal wrapper, and shell-based plugin engine uses it. A sysfetch script or binary, and there are a million, each with their own way of cutting down terminal execution time and nifty ascii graphics, even anime characters.

Note

WTH is a VOLINIT a tiny furry creature? Similar to a ferret? No. That’s a vole usually hunted by those guys that train BDSM hawks. I guess now would be a good place to take a minute and explain the name volinit which stands for volatile init. Thats it. I can feel the letdown over the net. insert “The Price is Right” loser horn That de-escalated quickly, didn’t it. I’m leaving it in. It’s an honest, editorial decision. Feel free to tell me I’m dumb. It’s not a secret I have ever been able to keep. =D

I think I’m the only one who enjoys customizing his linux desktop with the latest and greatest cough niri with cough Noctalia v5 cough cough who does NOT have an unhealthy obsession, and all around fascination with, Anime. I watched Ninja Scroll a million times as a teenager, I would race home to watch Dragon Ball Z, and in college I smoked..uh haha, drank, um…no, um. commiserated with like-minded individuals between study sessions in college, with Adult Swim as the background noise..every…MOST nights, weekends. Ya, whew. HOWEVER that tiny piece of a younger life never survived into my adulthood, and whenever I see the wallpapers, utilities, plugins, etc. from the communities hosting them, I get the mental image of children running companies, github repos, advanced design and coding projects, etc. It almost infantilises it, if it weren’t for tentacle porn…but thats a blog post for another day.

This is NOT one of those. This is My sysfetch that’s not a sysfetch. This is my attempt to create a terminal init, that would serve the same purpose as a sysfetch but with cooler ascii graphics, color schemes, and none of those specs about my own system, that I already know and don’t need to be told every time I start a new shell, or open my terminal. Just for posterity I understand why sysfetch’s are used and necessary when showing off desktops, THIS POST HAS NOTHING TO DO WITH THAT USECASE. This is a complete post-mortem and engineering breakdown of building volinit from static Nim compilation and Nix Flake integration to the nightmare of debugging ANSI-colored trailing spaces OR that time I wanted colorful, flashy ascii graphics every time I opened my terminal instead of the same old, tired, sysfetch fare.


Part 0: Prologue: Let me take you back to XFCE 4, when I used to run Xubuntu

Back when I was still scared of Arch Linux, Pacman, and preferred the ease and simplicity of Ubuntu, I ran Xubuntu, because I’ve never been a fan of Gnome, KDE looked kinda cool, but had too much bloat, and at the time the only other decent alternative was Cinnamon, and if you didn’t/couldn’t run Linux Mint before LMDE, then XFCE was your only other decent alternative. Thus, you, I, the royal ‘you,’ chose Xubuntu. Back in these, my formative Linux years, I decided to write my own fetch app. I am completely self-taught so if there is some CompSci-101 class that EVERYONE has to write a fetch app, it would seem so given the sheer number of them that can be found on Github, I’m not aware of it.

Anyway, my stupidly simple and annoyingly deficient sysfetch app stayed with me until I found Endeavour OS a few years ago and I decided I needed something more modern. I found Nitch a fast fetch app, that was written in Nim, and forced you to update the source and re-compile if you wanted to customize it in anyway. I had never used Nim, the language that it was built on, and decided that it was the one for me. It would make me learn a little Nim, still giving me a modicum of the personalization that I had enjoyed for 10+ years, and would give me a modern, blazingly fast new fetch. That same source-code is what I thought of when I made Volinit, which is written in Nim, piped through my Nix flake and compiles at build.

Part 1: Architecture, Nim + Nix, and the Three-Phase Overhaul

Why traditional system banners suck

Most terminal greeting utilities fall into one of two traps:

  1. Heavy script bloat: Python/Node scripts or heavy shell functions that spawn 15 subprocesses (uname, uptime, lsb_release, git status) and add 150–300ms latency every time you split a pane in tmux or launch a subshell.
  2. Telemetry dumps: Tools like neofetch or fastfetch prioritize printing endless lines of RAM usage, GPU drivers, and kernel strings rather than creating an iconic visual workspace identity.

When operating on a tmpfs-root NixOS machine, interactive terminal speed is critical. Launching a shell must be instant (sub-5ms), visually distinct, and zero-cost when piped or invoked non-interactively.

That was the initial pitch for volinit: turn a static banner into a terminal session identity engine. It had to be sub-5ms, single-binary, fully responsive to terminal width, and packaged as a standalone Nix Flake input.

       +-------------------------------------------------------+
       |                  volinit Architecture                 |
       +-------------------------------------------------------+
       |  [CLI Flags] > [Env Vars] > [~/.config] > [/etc]      |
       +-------------------------------------------------------+
                                   |
                                   v
       +-------------------------------------------------------+
       |               RenderPlan Engine (Nim)                 |
       |  - Width Gates (≥163, 100-159, 60-99, <60)           |
       |  - Zero-Subprocess Probes (/etc/os-release, .git/HEAD) |
       |  - ANSI-Aware Visual Width & Trailing Strip           |
       +-------------------------------------------------------+
                                   |
           +-----------------------+-----------------------+
           |                                               |
           v                                               v
    [Interactive TTY]                              [Pipe Mode]
    TrueColor Block Art + Figlet               Plain Key-Value Text

Key architectural decisions

1. Compile-Time Asset Embedding via staticRead

Runtime file reads add filesystem latency and risk broken path dependencies when binaries are copied across systems or installed in Nix store paths. Nim’s staticRead bakes every ASCII art asset into the binary at compile time instead: the 46×92 Intel 4004 chip die badge, the smaller monogram variants, all of it.

Tradeoff: Updating art requires re-compiling. Gotcha: Nix Flakes build strictly from git-tracked trees. Adding a new asset file without staging it (git add) causes staticRead to fail instantly during nix build.

2. Decoupled Flake Input Consumption Model

The project is built with buildNimPackage inside a Nix Flake and consumed as an input to ~/.nix-config. This decouples binary iteration from system rebuilds. Upgrading the live shell is a two-step process: build and verify locally, push, then update the flake lock in ~/.nix-config.

3. Compositional RenderPlan over Line-by-Line Appends

Instead of hardcoded stdout prints, rendering is driven by a single RenderPlan structure:

type RenderPlan = object
  banner: string         # Selected ASCII artwork
  titleBlock: string     # Block figlet or wordmark
  cells: seq[MetaCell]   # Metadata (OS, USER, git HEAD, battery)
  palette: ColorMap      # Active color theme
  mode: RenderMode       # Split-panel | Hero | Compact | Monogram
  width: int             # Measured terminal width
  height: int            # Measured terminal height

The three-phase evolution

Phase 1: Foundation & Scaffolding (52cf979)

I laid out the modules, the repo structure, and the two invariants I refused to break: sub-5ms startup, Nix build isolation. Phase 1 changed no product code. It only drew the boundary.

Phase 2: RenderEngine Refactor (f39d81e)

Then I split the core logic into three modules:

  • layout.nim: Evaluates terminal dimensions and assigns execution modes based on width gates:
    • ≥160 cols: split-panel (art left, metadata right).
    • 100–159 cols: hero (badge top, title bottom).
    • 60–99 cols: compact (14×28 badge top, title/wordmark).
    • <60 cols: monogram (wordmark line).
    • Non-TTY Pipe: Emits plain key-value text (!isatty(stdout)).
  • probes.nim: Fast, zero-subprocess data acquisition.
    • OS: Parses /etc/os-release directly without calling uname.
    • Git: Reads .git/HEAD manually (~1ms) without spawning git.
    • Battery: Directly reads /sys/class/power_supply/BAT*/capacity.
  • config.nim: 5-layer precedence loader (Compiled defaults → /etc/volinit/config.toml~/.config/volinit/config.toml → Environment variables → CLI flags). Note: Parser uses std/parsecfg (INI format under a .toml filename for simple key = value structures).

Phase 3: Themes, Export, and Generation (3c87059)

  • Theme Engine (themes.nim): Built-in themes (chip-green, mono, synthwave) and user theme packs (~/.config/volinit/themes/). Built-in themes resolve first to avoid stat calls during default startup.
  • Export Command (export_cmd.nim): volinit export --format ansi|svg for generating shareable banners.
  • Generator Command (generate_cmd.nim): volinit generate --from-image PATH auto-detecting system converters like jp2a, chafa, or img2txt.

Part 2: The Nightmares of Terminal Math & The Split-Panel Autopsy

Phase 4 Bug Sweep: 5 Edge Cases in Product Code (0d6df0f)

Running parallel worker agents let me move fast, and it also let five edge cases through. I swept them before release:

  1. Unquoted Path Injection in generate: Image paths containing spaces failed in external shell invocations. Fixed with explicit quoteShell.
  2. Unwired postProcessArt: Custom converted art skipped normalization routines.
  3. Compact/Monogram Render Failures: Narrow terminal widths caused invalid line wraps due to missing badge size degradation logic.
  4. Filesystem Overhead in Theme Lookup: Probing user theme directories added unnecessary disk IO on cold starts.
  5. Missing Figlet Fallbacks: Terminals under 67 columns broke when evaluating block font widths. Added fallback to plain wordmarks.

The Split-Panel Shearing Autopsy (99ba62d)

The most complex bug surfaced when running on wide monitors (≥160 columns) in split-panel mode. The LOWCACHE figlet banner displayed severe diagonal shearing — each row of text shifted rightward by varying offsets:

[ASCII CHIP BADGE]    L O W C A C H E
[ASCII CHIP BADGE]      L O W C A C H E
[ASCII CHIP BADGE]        L O W C A C H E

The issue stemmed from three stacked width-measurement bugs:

Bug 1: ANSI Background Color Cells & Trailing Space Stripping

jp2a renders black background cells in art using explicit TrueColor ANSI background codes followed by a space: \x1b[38;2;0;0;0m . Standard string stripping (strip()) strips literal whitespace, but fails when spaces are embedded inside ANSI escape codes. As a result:

  • visualWidth() stripped ANSI sequences after whitespace trimming, leaving trailing space character sequences intact.
  • Because the chip badge has curved edges, different rows had different numbers of trailing colored spaces.
  • The alignment math measured each row’s width inconsistently, leading to variable padding offsets per line.
Incorrect Trailing Strip Sequence:
Raw Row:  "BADGE_ART\x1b[38;2;0;0;0m   \x1b[0m"
strip():  "BADGE_ART\x1b[38;2;0;0;0m   \x1b[0m"  (Spaces inside ANSI block ignored!)
width:    Measured length includes invisible background spaces -> Off-by-N per row!

Bug 2: Nim’s strip() Default Parameter Trap

In Nim, calling strip(trailing=true) defaults leading=true as well unless explicitly overridden (strip(leading=false, trailing=true)). When measuring line widths, leading spaces on de-indented rows were stripped during width calculation, undercounting actual printable width and causing a hidden 3-column rightward drift across hero banners.

Bug 3: Gate Width vs. True Content Width

The split-panel layout gate was configured for ≥160 columns. However, the actual required content width was 163 columns (92 columns art badge + 4 columns margin gap + 67 columns figlet text). At 160–162 columns, split-panel rendered without falling back to hero mode, causing terminal line wrapping.


The Fix: stripTrailingVisual() and Uniform Left Padding

To resolve the shearing and drift:

  1. stripTrailingVisual() Implementation: Created a custom procedure in ansi.nim that parses through ANSI sequences and strips whitespace from the visual trailing edge before measuring length or printing.
  2. Explicit Strip Parameters: Updated visualWidth() to use strip(leading=false, trailing=true).
  3. Block-Uniform Left Padding: Multi-line text blocks (figlet titles and art rows) are padded using a single uniform left offset calculated from the widest row in the block, rather than computing per-line centering offsets.
  4. Accurate Gate Fallbacks: Corrected split-panel content width checks to 163 columns, falling back to stacked hero mode on 160–162 column displays.
# Fix snippet in src/volinitpkg/ansi.nim
proc stripTrailingVisual*(s: string): string =
  # Removes trailing whitespace through ANSI SGR escape codes 
  # while preserving color resets at the end of the line.

Final verification and performance metrics

I re-checked every layout invariant after 99ba62d:

MetricTargetAchievedStatus
Startup Speed< 100ms2–3msPASS
DependenciesZero runtime0 (static binary)PASS
Split-Panel Alignment (180 cols)Column 100 uniform startColumn 100 exactPASS
160–162 Col FallbackStacked hero fallbackHero triggeredPASS
Pipe Mode (volinit | cat)Plain text, no ANSIClean key-valuePASS

Checklist for terminal UI developers

  • Never trust standard strip() on ANSI strings: Trailing spaces inside escape codes will corrupt width calculations.
  • Compute visual width from grapheme clusters: Never use raw byte or rune counts when TrueColor or wide Unicode characters are present.
  • Pad multi-line blocks uniformly: Centering lines individually causes shearing if line lengths vary.
  • Gate layouts on true rendered width: Ensure gap margins and padding are included when evaluating breakpoint thresholds.

Written from the machine it describes. More field notes: the archive · RSS.

Working on something in this territory? I take on scoped engagements.