TUI Device Experience Research

Summary: Evaluating approaches for a unified TUI experience on styrene fleet devices that can’t or shouldn’t run a full graphical desktop environment (COSMIC, GNOME, etc.). Recommends Approach D (Hybrid): Zellij as session shell with styrened.tui running as a dashboard pane — combining battle-tested multiplexing with custom styrene UI. Secondary recommendation: textual-web for browser-based access to headless devices.


Context

Styrene fleet devices span a resource spectrum:

TierExampleRAMFeasible
HeadlessPi Zero 2W512 MBConsole only, SSH target
ConstrainedT100TA2 GBLight GUI possible but wasteful
CapableRPi 4B, x864-8 GBFull DE or TUI by choice

Tier 1 devices are SSH targets running styrened — the operator interacts via the TUI on their workstation. Tier 2-3 devices may boot into a local TUI experience for direct console interaction. All tiers benefit from a consistent operational shell that provides device identity, mesh status, and fleet context without requiring a graphical stack.

The question: what does a unified, lightweight operational environment look like for devices where a full NixOS desktop is either impossible or undesirable?


Landscape Survey

Prior Art: desktop-tui (Julien-cpsn)

desktop-tui is a Rust proof-of-concept (~1,300 LOC) using AppCUI-rs that renders a floating-window desktop metaphor inside a terminal. TOML-defined app shortcuts, taskbar, tiling modes.

Assessment: Experimental (author’s own warning: “made in 2 days”). The ANSI terminal parser covers basics (SGR colors, cursor movement, erase) but misses scroll regions, alternate screen buffer, mouse support — critical for hosting real TUI apps. No PTY allocation (uses pipes), no session persistence, no plugin system. The demo GIF looks impressive; the terminal emulation underneath is a sketch. Not suitable as a foundation, but validates the concept of a text-mode desktop metaphor.

vtm (Monotty Desktopio)

vtm — C++, 3.2k stars, actively maintained. The most complete text-based desktop environment available.

Capabilities:

  • Floating and tiling window management within the terminal
  • Infinite pannable workspace
  • Collaborative session sharing — multiple users connect to same desktop
  • Full binary serialization of UI state (detach/reattach)
  • Session sharing over LAN (SSH tunnels, TCP, inetd)
  • Wraps any console application, nestable indefinitely

Limitations:

  • No extension/plugin system — customization requires forking the C++ codebase
  • No Nix package currently (would need packaging work)
  • No integration points for styrened without external status programs
  • Less community adoption than tmux/Zellij

Zellij

Zellij — Rust, “terminal workspace with batteries included.” The modern tmux alternative.

Capabilities:

  • Tiling and floating pane layouts defined in KDL
  • Session persistence (detach/reattach)
  • WASM plugin system — custom UI in any language compiling to WebAssembly
  • Built-in status bar, tab management
  • ~15MB RSS, minimal runtime overhead
  • NixOS native: programs.zellij.enable = true

Plugin system detail: Plugins render by printing to stdout within their allocated pane. They receive events (key presses, timer ticks, session state changes) and can call Zellij APIs (open panes, run commands, change layouts). Background worker threads available for async operations. Built-in plugins include the status bar, session manager, and config screen.

tmux

The incumbent. C, universally available, massive ecosystem. No plugin system beyond status bar scripting and external tools. Proven but static.

Textual + textual-web

Textual with textual-web can serve TUI applications to a web browser over WebSockets. This enables browser-based access to the TUI without SSH.

Relevant capability: textual-web can function as an SSH-like session in a browser tab. A fresh URL drops into a TUI application with full interactivity. This is unique among the options — no other approach offers browser-based access without additional infrastructure.

greetd + tuigreet

greetd is a minimal, agnostic login manager daemon. tuigreet is a console-based greeter. Together they provide TUI-native login on NixOS without any graphical stack. Both available in nixpkgs.

Cage (Wayland Kiosk)

Cage is a Wayland kiosk compositor that runs a single maximized application. An option if minimal Wayland is acceptable — runs a fullscreen terminal emulator (foot, alacritty) displaying the TUI session. Adds ~20MB overhead but gains proper font rendering and input handling. Worth considering for Tier 2-3 devices.


Architectural Approaches

Approach A: Zellij as Session Shell (Standalone)

greetd/tuigreet → auto-login → zellij --layout styrene.kdl

Custom Zellij WASM plugins provide fleet status, mesh indicators, and styrened IPC integration. Each pane is a native terminal — run any tool. Layouts shipped per device type.

What the user sees:

┌─ styrene: rpi-01 ────────────────────── MESH: ✓ ── 14:32 ─┐
│ ┌─ Status ──────────────┐ ┌─ Shell ──────────────────────┐ │
│ │ UPTIME: 14d 3h       │ │ $ systemctl status styrened  │ │
│ │ MEM:    128/512 MB   │ │ ● styrened.service - Active  │ │
│ │ DISK:   4.2/28 GB    │ │   since Mon 2026-01-26...    │ │
│ │ TEMP:   42°C         │ │                               │ │
│ │ RNS:    CONNECTED    │ │ $                             │ │
│ └───────────────────────┘ └──────────────────────────────┘ │
│ <Alt+1> Status  <Alt+2> Shell  <Alt+3> Logs  <Alt+N> New  │
└────────────────────────────────────────────────────────────┘
ProsCons
Battle-tested multiplexingWASM plugins need Rust toolchain
Session persistenceDashboard is separate process, not integrated
~15MB RSSNo unified fleet view locally
NixOS nativeAesthetic limited to terminal colors
Identical over SSHSeparate codebase from the TUI

Approach B: vtm as Text Desktop

greetd/tuigreet → auto-login → vtm

vtm provides the full desktop metaphor — floating windows, tiling, collaborative sessions. Console apps run in managed windows.

ProsCons
Closest to “real DE” in text modeNo extension system (fork to customize)
Multi-user session sharingNeeds Nix packaging
Full state detach/reattachNo styrened integration without external tools
Cross-platformLess community than Zellij/tmux

Approach C: Textual TUI as Login Shell

greetd → auto-login → styrene --local-mode

Boot directly into styrened.tui as the session shell. The TUI is the operating environment with embedded terminal panes via pyte + ptyprocess.

ProsCons
Unified codebase (Python/Textual)Python terminal emulation bottleneck
Imperial CRT theme everywhereNo session persistence
Native styrened IPCSingle app — arbitrary tools only via embedded pane
textual-web enables browser accessPython import overhead on constrained hardware
Shared widgets/models with fleet TUIKey-to-escape reversal problem
greetd → auto-login → zellij --layout styrene.kdl
                              ├─ pane: styrene --dashboard
                              ├─ pane: shell (bash/zsh)
                              └─ pane: journalctl -f -u styrened

Zellij provides session management and multiplexing. The TUI runs as one pane within it. An optional Zellij WASM status bar plugin shows mesh/RNS indicators.

ProsCons
Battle-tested multiplexing + custom UITwo technology stacks (Rust/WASM + Python)
Session persistenceAesthetic seam between Zellij and the TUI
No Python terminal emulation neededZellij WASM API still evolving
The TUI focuses on dashboardsAdditional config surface (KDL layouts)
Operator keeps full shell access
Reproducible via NixOS config
Identical experience over SSH

Comparison Matrix

FactorA: Zellij SoloB: vtmC: Textual ShellD: Hybrid
Resource overheadLow (~15MB)MediumMedium (Python)Low-Medium
Session persistenceYesYesNoYes
Custom dashboardWASM pluginExternal procNative widgetNative widget
Shell accessNativeNativeEmbedded (Python)Native
Codebase unitySeparate (Rust)Separate (C++)Unified (Python)Mixed
Imperial CRT themeTerminal colors onlyTerminal colors onlyFull TCSSPartial
SSH experienceIdenticalIdenticalIdenticalIdentical
textual-web supportNoNoYesPartial
NixOS integrationExcellentNeeds packagingGoodExcellent
MaturityHighMediumLow (build it)High foundation

Recommendation

Approach D (Hybrid) is the pragmatic path.

Rationale

  1. The TUI already exists. Adding --local-mode / --dashboard showing device-local status via styrened IPC is incremental work, not a new project.

  2. Zellij solves the hard problems. Session persistence, terminal emulation, multiplexing, resize handling — all identified as pain points in Python-based terminal emulation.

  3. NixOS makes composition trivial. A NixOS module wires greetd + Zellij + styrened.tui into a single enable = true option per device type.

  4. Operator workstation is unchanged. styrened.tui in full fleet management mode runs on the operator machine with or without Zellij wrapping it.

  5. Browser access via textual-web is additive. Devices can additionally serve styrene --dashboard via textual-web for browser-based monitoring without SSH.

Where Approach C Wins

If browser-based access (textual-web) becomes the primary access pattern for headless devices, Approach C’s unified Python stack has an advantage. This can coexist: Zellij for local console/SSH, textual-web for browser access, both displaying the same styrened.tui dashboard.


Implementation Plan

Phase 1: NixOS Session Foundation

Goal: Devices boot into a Zellij session automatically. No desktop environment, no manual login on fleet devices.

Deliverables:

  • NixOS module styrene.session wiring greetd + tuigreet + Zellij
  • Per-device-type KDL layout files
  • Imperial CRT terminal color scheme (16-color palette for terminal emulators)
  • Auto-login configuration for fleet devices (operator workstations remain manual login)

Dependencies: None (foundation phase). Effort: M Benefits: All tiers. Tier 1 (headless) gets a consistent SSH landing. Tier 2-3 get a local console experience.

NixOS Module

# modules/styrene-session.nix
{ config, lib, pkgs, ... }:

let
  cfg = config.styrene.session;
  layoutFile = ./layouts + "/${cfg.deviceType}.kdl";
in {
  options.styrene.session = {
    enable = lib.mkEnableOption "Styrene TUI session";

    deviceType = lib.mkOption {
      type = lib.types.enum [ "headless" "constrained" "capable" ];
      default = "constrained";
      description = "Device tier — determines Zellij layout and resource allocation.";
    };

    autoLogin = lib.mkOption {
      type = lib.types.bool;
      default = true;
      description = "Auto-login to Zellij session (fleet devices). Disable for operator workstations.";
    };

    user = lib.mkOption {
      type = lib.types.str;
      default = "styrene";
      description = "User account for the auto-login session.";
    };
  };

  config = lib.mkIf cfg.enable {
    # greetd as login manager
    services.greetd = {
      enable = true;
      settings = {
        default_session = if cfg.autoLogin then {
          command = "${pkgs.zellij}/bin/zellij --layout ${layoutFile}";
          user = cfg.user;
        } else {
          command = "${pkgs.greetd.tuigreet}/bin/tuigreet --cmd '${pkgs.zellij}/bin/zellij --layout ${layoutFile}' --theme 'text=green;border=darkgreen;prompt=lightgreen'";
        };
      };
    };

    # Zellij configuration
    programs.zellij.enable = true;

    # Imperial CRT terminal colors via console
    console.colors = [
      "0a0a0a" # black (background)
      "ff6b6b" # red (danger)
      "228b22" # green (dim phosphor)
      "ffa94d" # yellow (warning)
      "74c0fc" # blue (info)
      "1a5c1a" # magenta (repurposed: dark green)
      "69db7c" # cyan (repurposed: success)
      "32cd32" # white (medium phosphor — standard text)
      "0d2d0d" # bright black (surface)
      "ff6b6b" # bright red
      "39ff14" # bright green (bright phosphor — highlights)
      "ffa94d" # bright yellow
      "74c0fc" # bright blue
      "228b22" # bright magenta (repurposed: dim phosphor)
      "69db7c" # bright cyan
      "39ff14" # bright white (accent)
    ];
  };
}

KDL Layouts

Headless (headless.kdl) — SSH landing only, single pane:

layout {
    pane
    pane size=1 borderless=true {
        plugin location="zellij:status-bar"
    }
}

Constrained (constrained.kdl) — T100TA and similar, dashboard + shell:

layout {
    pane split_direction="vertical" {
        pane size="30%" command="styrene" {
            args "--dashboard"
        }
        pane focus=true
    }
    pane size=1 borderless=true {
        plugin location="zellij:status-bar"
    }
}

Capable (capable.kdl) — RPi 4B, x86, full layout:

layout {
    tab name="ops" focus=true {
        pane split_direction="vertical" {
            pane size="30%" command="styrene" {
                args "--dashboard"
            }
            pane split_direction="horizontal" {
                pane focus=true
                pane size="30%" command="journalctl" {
                    args "-f" "-u" "styrened" "--no-hostname"
                }
            }
        }
        pane size=1 borderless=true {
            plugin location="zellij:status-bar"
        }
    }
    tab name="shell" {
        pane
    }
}

Imperial CRT Zellij Theme

// themes/imperial-crt.kdl
themes {
    imperial-crt {
        fg "#32cd32"
        bg "#0a0a0a"
        black "#0d2d0d"
        red "#ff6b6b"
        green "#39ff14"
        yellow "#ffa94d"
        blue "#74c0fc"
        magenta "#228b22"
        cyan "#69db7c"
        white "#32cd32"
        orange "#ffa94d"
    }
}

Phase 2: TUI Dashboard Mode

Goal: The TUI gains a --dashboard flag that renders a device-local status view. Reads from styrened’s Unix socket IPC, not the mesh. Designed to run as a Zellij pane, not a fullscreen takeover.

Deliverables:

  • --dashboard / --local-mode CLI flag for styrene
  • IPC client reading from styrened Unix socket (/run/styrened/control.sock)
  • Dashboard widgets: uptime, memory, disk, temperature, RNS connectivity, LXMF queue depth
  • Compact layout adapting to pane dimensions (min viable: 25 cols x 15 rows)
  • Imperial CRT theme applied via existing TCSS

Dependencies: Phase 1 (layout references styrene --dashboard). Requires styrened IPC Control Server (implemented). Effort: M Benefits: Tier 2-3 (local console), Tier 1 via SSH if operator runs Zellij remotely.

CLI Entry Point

# styrened.tui __main__.py addition
@click.command()
@click.option("--dashboard", is_flag=True, help="Device-local dashboard mode (Zellij pane)")
def main(dashboard: bool):
    if dashboard:
        from styrene.screens.dashboard_local import LocalDashboardApp
        LocalDashboardApp().run()
    else:
        from styrene.app import StyreneApp
        StyreneApp().run()

IPC Data Contract

styrened’s IPC Control Server already exposes a Unix socket. The dashboard queries it for local device state:

IPC CommandResponse FieldsWidget
statusuptime, load, memory, disk, tempSystem vitals
rns statusconnected, interfaces, announce_countRNS connectivity
lxmf statusqueue_depth, last_delivery, propagation_nodeLXMF status
identitydisplay_name, hash, addressDevice identity header

Dashboard Widget Layout

┌─ rpi-01 [3f8a...c21b] ──────┐
│ UP  14d 3h  │ TEMP  42°C    │
│ MEM 128/512 │ DISK 4.2/28G  │
│ LOAD 0.12   │               │
├─ RETICULUM ──────────────────┤
│ STATUS: CONNECTED            │
│ IFACE:  WiFi ✓  LoRa ✓      │
│ PEERS:  7 known              │
├─ LXMF ───────────────────────┤
│ QUEUE: 0  │ LAST: 2m ago    │
│ PROP:  styrene.hub [OK]      │
└──────────────────────────────┘

At narrow widths (<40 cols), the dashboard drops to a single-column stack. At very narrow widths (<25 cols), it displays only the most critical metrics (uptime, RNS status, queue depth).

Refresh Strategy

  • Poll IPC socket every 5 seconds (set_interval)
  • No push mechanism needed — local socket is fast
  • If styrened is unreachable, display DAEMON: UNREACHABLE in danger color and retry with backoff

Phase 3: Zellij Status Bar Plugin (WASM)

Goal: A Zellij status bar plugin showing mesh identity, RNS connectivity, and clock. Always visible at the bottom of every Zellij layout, replacing or augmenting the default status bar.

Deliverables:

  • Rust crate compiling to WASM (styrene-zellij-plugin)
  • Status bar showing: device name, truncated RNS hash, mesh status indicator, UTC clock
  • Reads from a lightweight status file (/run/styrened/status.json) written by styrened
  • Imperial CRT colors via Zellij plugin API
  • NixOS integration: plugin WASM binary included in Zellij config via Nix

Dependencies: Phase 1 (Zellij must be the session shell). styrened must write a status file (trivial addition to existing IPC server — write JSON sidecar on each status update). Effort: M Benefits: All tiers. The status bar is visible in every pane, every tab — persistent fleet awareness.

Plugin Architecture

styrened → writes /run/styrened/status.json (every 10s)

Zellij plugin (WASM) → reads file on Timer event

Renders status bar: [styrene: rpi-01] [3f8a] [MESH: ✓] [Q:0] [14:32 UTC]

The plugin does NOT connect to the Unix socket directly. WASM plugins in Zellij have limited I/O — file reads are supported, socket connections are not. styrened writes a JSON sidecar as a one-line file:

{"name":"rpi-01","hash":"3f8a","rns":"connected","lxmf_queue":0,"uptime":1234567}

Plugin Scaffold (Rust)

use zellij_tile::prelude::*;

#[derive(Default)]
struct StyreneStatusPlugin {
    status: Option<DeviceStatus>,
}

#[derive(serde::Deserialize)]
struct DeviceStatus {
    name: String,
    hash: String,
    rns: String,
    lxmf_queue: u32,
    uptime: u64,
}

impl ZellijPlugin for StyreneStatusPlugin {
    fn load(&mut self, _config: BTreeMap<String, String>) {
        set_timeout(10.0); // refresh every 10s
        request_permission(&[
            PermissionType::ReadApplicationState,
        ]);
    }

    fn update(&mut self, event: Event) -> bool {
        match event {
            Event::Timer(_) => {
                // Read status file
                if let Ok(data) = std::fs::read_to_string("/run/styrened/status.json") {
                    self.status = serde_json::from_str(&data).ok();
                }
                set_timeout(10.0);
                true // re-render
            }
            _ => false,
        }
    }

    fn render(&mut self, _rows: usize, cols: usize) {
        match &self.status {
            Some(s) => {
                let mesh = if s.rns == "connected" { "✓" } else { "✗" };
                let now = chrono::Utc::now().format("%H:%M");
                print!(
                    " styrene: {} [{}] MESH:{} Q:{} {}",
                    s.name, &s.hash[..4.min(s.hash.len())], mesh, s.lxmf_queue, now
                );
            }
            None => print!(" styrene: LOADING..."),
        }
    }
}

register_plugin!(StyreneStatusPlugin);

KDL Layout Integration

Replace the default status bar in layout files:

pane size=1 borderless=true {
    plugin location="file:/run/current-system/sw/share/zellij/plugins/styrene-status.wasm"
}

Phase 4: textual-web Integration (Optional)

Goal: Serve the styrened.tui dashboard via textual-web for browser-based access. Headless Tier 1 devices become monitorable from any browser on the LAN without SSH.

Deliverables:

  • systemd service running textual-web serve styrene --dashboard
  • NixOS module option styrene.session.textualWeb.enable
  • Bind to localhost by default; optional LAN exposure with explicit opt-in
  • Basic security: listen address restriction, optional reverse proxy integration notes

Dependencies: Phase 2 (dashboard mode must exist). textual-web must be packaged for Nix (or run via nix run). Effort: S Benefits: All tiers, especially Tier 1 headless devices. Browser access eliminates SSH requirement for monitoring.

NixOS Module Extension

# Addition to styrene-session.nix
options.styrene.session.textualWeb = {
  enable = lib.mkEnableOption "Serve dashboard via textual-web";

  listenAddress = lib.mkOption {
    type = lib.types.str;
    default = "127.0.0.1";
    description = "Bind address. Set to 0.0.0.0 for LAN access (requires explicit opt-in).";
  };

  port = lib.mkOption {
    type = lib.types.port;
    default = 8501;
    description = "Port for textual-web server.";
  };
};

# systemd service
config.systemd.services.styrene-dashboard-web = lib.mkIf cfg.textualWeb.enable {
  description = "Styrene Dashboard (textual-web)";
  after = [ "network.target" "styrened.service" ];
  wantedBy = [ "multi-user.target" ];
  serviceConfig = {
    ExecStart = "${pkgs.textual-web}/bin/textual-web serve styrene -- --dashboard --host ${cfg.textualWeb.listenAddress} --port ${toString cfg.textualWeb.port}";
    User = cfg.user;
    Restart = "on-failure";
    RestartSec = 5;
  };
};

Security Considerations

ConcernMitigation
Unauthenticated accessDefault bind to localhost; LAN exposure requires explicit listenAddress = "0.0.0.0"
Data exposureDashboard is read-only (status display, no RPC commands)
Network attack surfacetextual-web serves WebSocket + static assets only; no shell access
Production hardeningPlace behind Traefik with Keycloak auth for non-localhost deployments

textual-web currently has no built-in authentication. For LAN-exposed deployments, the recommendation is a reverse proxy (Traefik, Caddy) with SSO integration. The dashboard intentionally excludes command execution — it is a read-only status view.

Phase 5: Cage Wayland Kiosk (Optional, Tier 2-3)

Goal: For devices with physical screens where proper font rendering matters (VT323 monospace, Imperial CRT aesthetic at its best), run Cage as a minimal Wayland compositor displaying a fullscreen terminal emulator.

Deliverables:

  • NixOS module option styrene.session.wayland.enable (mutually exclusive with console-only mode)
  • Cage launching foot terminal with Zellij inside
  • VT323 font configuration
  • Overhead assessment: Cage + foot RAM usage on constrained hardware

Dependencies: Phase 1 (Zellij layout files reused). Font and theme configuration are additive. Effort: S Benefits: Tier 2-3 only. Tier 1 headless devices have no display — this phase is irrelevant for them.

NixOS Module Extension

# Addition to styrene-session.nix
options.styrene.session.wayland = {
  enable = lib.mkEnableOption "Cage Wayland kiosk (for devices with screens)";

  terminal = lib.mkOption {
    type = lib.types.enum [ "foot" "alacritty" ];
    default = "foot";
    description = "Terminal emulator to run inside Cage. foot is lighter (~5MB RSS).";
  };
};

config = lib.mkIf (cfg.enable && cfg.wayland.enable) {
  # Override greetd to launch Cage instead of direct Zellij
  services.greetd.settings.default_session.command = lib.mkForce
    "${pkgs.cage}/bin/cage -s -- ${pkgs.${cfg.wayland.terminal}}/bin/${cfg.wayland.terminal} -e ${pkgs.zellij}/bin/zellij --layout ${layoutFile}";

  # Font configuration
  fonts = {
    packages = [ pkgs.vt323 ];
    fontconfig.defaultFonts.monospace = [ "VT323" "monospace" ];
  };
};

foot Terminal Configuration

# /etc/xdg/foot/foot.ini (managed by NixOS)
[main]
font=VT323:size=14
pad=4x4

[colors]
background=0a0a0a
foreground=32cd32
regular0=0d2d0d
regular1=ff6b6b
regular2=228b22
regular3=ffa94d
regular4=74c0fc
regular5=1a5c1a
regular6=69db7c
regular7=32cd32
bright0=0a0a0a
bright1=ff6b6b
bright2=39ff14
bright3=ffa94d
bright4=74c0fc
bright5=228b22
bright6=69db7c
bright7=39ff14

Overhead Assessment

ComponentRSS (approx.)Notes
Cage~8 MBMinimal wlroots compositor
foot~5 MBWayland-native, GPU-accelerated text
Zellij~15 MBSame as console mode
Total additional~13 MBCage + foot over bare console

On a 2 GB T100TA, 13 MB additional overhead is negligible. On a 512 MB Pi Zero 2W, it is measurable but acceptable if the device has a display attached. Devices without displays should not enable this phase.

Phase Summary

PhaseEffortDependenciesTier 1 (Headless)Tier 2 (Constrained)Tier 3 (Capable)
1: NixOS Session FoundationMNoneSSH landingLocal consoleFull workspace
2: TUI DashboardMPhase 1, styrened IPCVia SSHLocal paneLocal pane
3: Zellij Status Bar (WASM)MPhase 1, styrened status fileSSH sessionsAlways visibleAlways visible
4: textual-web (Optional)SPhase 2Browser monitoringBrowser monitoringBrowser monitoring
5: Cage Wayland (Optional)SPhase 1N/A (no display)Proper fonts/renderingProper fonts/rendering

Phases 1-3 are the core path. Phases 4-5 are additive enhancements that can be pursued independently once their dependencies are met. The entire stack is composable via NixOS module options — a single styrene.session.enable = true with device-type selection produces a working environment.


Sources

Graph