I did not want to install Neofetch or any other modern version of it in my dom0 and those would not have worked properly anyway, so I ended up prompting this script to the existence.
It uses information from: - /etc/os-release - /proc/uptime - /proc/cpuinfo - xl info - qvm-ls --raw-list
I used the ASCII art from this post by @weyoun.six in my script.
import base64
import gzip
import io
import os
import subprocess
import shutil
import re
import textwrap
# --- 1. System Information Gathering ---
def get_os_release():
try:
with open("/etc/os-release") as f:
for line in f:
if line.startswith("PRETTY_NAME="):
return line.split("=")[1].strip().strip('"')
except Exception:
pass
return "Qubes OS"
def get_uptime():
try:
with open('/proc/uptime', 'r') as f:
uptime_seconds = float(f.readline().split()[0])
hours, remainder = divmod(int(uptime_seconds), 3600)
minutes, _ = divmod(remainder, 60)
return f"{hours}h {minutes}m"
except Exception:
return "Unknown"
def get_cpu_info():
try:
with open("/proc/cpuinfo") as f:
for line in f:
if line.startswith("model name"):
return line.split(":")[1].strip()
except Exception:
return "Unknown"
def get_xen_and_memory():
try:
xl_out = subprocess.check_output(['sudo', 'xl', 'info'], stderr=subprocess.DEVNULL).decode()
data = {}
for line in xl_out.splitlines():
if ":" in line:
key, val = line.split(":", 1)
data[key.strip()] = val.strip()
xen_ver = f"{data.get('xen_major', 'X')}.{data.get('xen_minor', 'X')}{data.get('xen_extra', '')}"
total_mem = int(data.get("total_memory", 0))
free_mem = int(data.get("free_memory", 0))
return xen_ver, total_mem - free_mem, total_mem
except Exception:
return "Unknown", 0, 0
def get_qubes_stats():
try:
total_out = subprocess.check_output(['qvm-ls', '--raw-list'], stderr=subprocess.DEVNULL).decode()
running_out = subprocess.check_output(['qvm-ls', '--raw-list', '--running'], stderr=subprocess.DEVNULL).decode()
return len([x for x in running_out.splitlines() if x.strip()]), len([x for x in total_out.splitlines() if x.strip()])
except Exception:
return 0, 0
# Store raw data as tuples so we can wrap the values independently of the colored labels
raw_info = [
("OS", get_os_release()),
("Kernel", os.uname().release),
("Hypervisor", f"Xen {get_xen_and_memory()[0]}"),
("Uptime", get_uptime()),
("Qubes", f"{get_qubes_stats()[0]} running / {get_qubes_stats()[1]} total"),
("CPU", get_cpu_info()),
("RAM", f"{get_xen_and_memory()[1]} MiB / {get_xen_and_memory()[2]} MiB")
]
# --- 2. Dynamic Scaling & Cropping Logic ---
def crop_ascii(ascii_lines):
lines = [line.rstrip() for line in ascii_lines]
while lines and not lines[0]:
lines.pop(0)
while lines and not lines[-1]:
lines.pop()
if lines:
min_indent = min(len(line) - len(line.lstrip()) for line in lines if line.strip())
lines = [line[min_indent:] for line in lines]
return lines
def scale_ascii(ascii_lines, target_width):
if not ascii_lines: return ascii_lines
original_width = max(len(line) for line in ascii_lines)
if original_width <= target_width or target_width <= 0:
return ascii_lines
padded_lines = [line.ljust(original_width) for line in ascii_lines]
scale_ratio = target_width / original_width
scaled_lines = []
num_rows = len(padded_lines)
target_height = max(1, int(num_rows * scale_ratio))
for i in range(target_height):
orig_y = min(int(i / scale_ratio), num_rows - 1)
orig_line = padded_lines[orig_y]
new_line = ""
for j in range(target_width):
orig_x = min(int(j / scale_ratio), original_width - 1)
new_line += orig_line[orig_x]
scaled_lines.append(new_line.rstrip())
return scaled_lines
# Setup dimensions to prevent layout overlap
try:
term_width = shutil.get_terminal_size().columns
except Exception:
term_width = 80
left_margin = 2
padding = 4
# --- 3. ASCII Art Processing ---
d = "H4sIAAAAAAAAA+3T0Q2AIAwE0H+nYBY3IN1/F4GgAXvXVuKPifdHvHtRoynFkvMWq51x61nFnOi6O+MTc+rP+Bz3+BUl7CVgTXwpgcTAGI8oPZSoYW83JRliEVyYjJ/4EvHCd8EUmeMRd0Z09I8KjM6AORQYEp9D4tFaCfjmbWBCCBAxLqfV62kRaZCMpxVBp80PHAZb0PEGAAA="
l = gzip.GzipFile(fileobj=io.BytesIO(base64.b64decode(d))).read().decode("utf-8")
c = {"Q": (48, 185, 214), "B": (29, 77, 140), "S": (4, 45, 107)}
raw_ascii_lines = l.split('\n')
raw_ascii_lines = crop_ascii(raw_ascii_lines)
# Cap the ASCII art at 45% of the terminal width so the info column always has room
orig_ascii_width = max((len(line) for line in raw_ascii_lines), default=0)
target_ascii_width = min(orig_ascii_width, max(15, int(term_width * 0.45)))
raw_ascii_lines = scale_ascii(raw_ascii_lines, target_ascii_width)
max_ascii_width = max((len(line) for line in raw_ascii_lines), default=0)
# --- 4. Text Wrapping & Formatting ---
# Calculate exactly how much space is left for the text
info_col_width = max(20, term_width - max_ascii_width - padding - left_margin)
info_lines = []
for label, value in raw_info:
combined_text = f"{label}: {value}"
# Calculate a hanging indent to align multi-line values perfectly
indent_len = len(label) + 2
indent_str = " " * indent_len if indent_len < info_col_width // 2 else " "
wrapped = textwrap.wrap(combined_text, width=info_col_width, subsequent_indent=indent_str)
if not wrapped:
continue
# Re-apply the blue ANSI color ONLY to the label in the first line
first_line = wrapped[0].replace(f"{label}:", f"\x1b[1;34m{label}\x1b[0m:", 1)
info_lines.append(first_line)
# Add any remaining wrapped lines
for line in wrapped[1:]:
info_lines.append(line)
# --- 5. Side-by-Side Output ---
print()
max_lines = max(len(raw_ascii_lines), len(info_lines))
for i in range(max_lines):
raw_line = raw_ascii_lines[i] if i < len(raw_ascii_lines) else ""
info_line = info_lines[i] if i < len(info_lines) else ""
colored_ascii = "".join([f"\x1b[38;2;{c[char][0]};{c[char][1]};{c[char][2]}m{char}\x1b[0m" if char in c else char for char in raw_line])
padding_spaces = max_ascii_width - len(raw_line) + padding
print(f"{' ' * left_margin}{colored_ascii}{' ' * padding_spaces}{info_line}")
print()