From 3dd65e9d7dbb6608563930a137ab5835b0785c6a Mon Sep 17 00:00:00 2001 From: "lukasz@orzechowski.eu" Date: Wed, 25 Mar 2026 16:55:19 +0100 Subject: [PATCH] v2.1.0: Pro-level upgrade, GPU processes, users panel, perf fixes - Gradient bars, braille sparklines, CPU user/sys/iowait breakdown - Memory buffers/cached/available, disk IOPS, per-interface network - GPU: VRAM breakdown, process list (NVIDIA+AMD), sparkline history - GPU: PCIe info, encoder/decoder, power limit, max clocks - USERS panel: per-user CPU/MEM/procs with login status - Process table: IO column, tree view (t), sort by IO (i), PID tracking - Adaptive layout: 3-col (140+), 2-col (80+), 1-col (narrow) - SYSTEM panel: ctx switches/s, interrupts/s, threads, FDs - Battery display, temperature mini-bars - Performance: slow-tick for expensive ops (temps, connections, fdinfo) - Decouple data update from rendering for responsive navigation - Stable sort with PID tiebreaker, selection tracked by PID - Intro disable prompt on 2nd run, mini-splash, --intro flag - SSH/exotic terminal fallback (ghostty, kitty, alacritty) - Alternate screen buffer: no clear on exit - Gitea repo renamed to entropymon - Published to PyPI Co-Authored-By: Claude Opus 4.6 (1M context) --- entropymon/__init__.py | 2 +- entropymon/monitor.py | 1383 ++++++++++++++++++++++++++++++++-------- pyproject.toml | 6 +- 3 files changed, 1128 insertions(+), 263 deletions(-) diff --git a/entropymon/__init__.py b/entropymon/__init__.py index 783308e..5f7a239 100644 --- a/entropymon/__init__.py +++ b/entropymon/__init__.py @@ -1,3 +1,3 @@ """entropymon - Terminal system monitor by Electric Entropy Lab.""" -__version__ = "1.0.0" +__version__ = "2.1.0" diff --git a/entropymon/monitor.py b/entropymon/monitor.py index 3c2cd82..cd21d27 100755 --- a/entropymon/monitor.py +++ b/entropymon/monitor.py @@ -6,6 +6,7 @@ import os import signal import sys import time +import random as _random from collections import deque from datetime import timedelta @@ -53,11 +54,33 @@ TRANSLATIONS = { "gpu": {"en": "GPU", "pl": "GPU", "de": "GPU", "es": "GPU", "fr": "GPU", "uk": "GPU", "zh": "GPU"}, "temps": {"en": "TEMPS", "pl": "TEMP", "de": "TEMP", "es": "TEMP", "fr": "TEMP", "uk": "TEMP", "zh": "WENDU"}, "processes": {"en": "PROCESSES", "pl": "PROCESY", "de": "PROZESSE", "es": "PROCESOS", "fr": "PROCESSUS", "uk": "PROCESY", "zh": "JINCHENG"}, + "system": {"en": "SYSTEM", "pl": "SYSTEM", "de": "SYSTEM", "es": "SISTEMA", "fr": "SYSTEME", "uk": "SYSTEMA", "zh": "XITONG"}, + "battery": {"en": "BATTERY", "pl": "BATERIA", "de": "BATTERIE", "es": "BATERIA", "fr": "BATTERIE", "uk": "BATAR.", "zh": "DIANCHI"}, + "users": {"en": "USERS", "pl": "UZYTKOWNICY", "de": "BENUTZER", "es": "USUARIOS", "fr": "UTILISATEURS", "uk": "KORYSTUVACHI", "zh": "YONGHU"}, + "logged_in": {"en": "logged in", "pl": "zalogowanych", "de": "angemeldet", "es": "conectados", "fr": "connectes", "uk": "v systemi", "zh": "yidenglu"}, # Disk "read": {"en": "Read", "pl": "Odczyt", "de": "Lesen", "es": "Leer", "fr": "Lire", "uk": "Chyt.", "zh": "Du"}, "write": {"en": "Write", "pl": "Zapis", "de": "Schreib", "es": "Escrib", "fr": "Ecrir", "uk": "Zapys", "zh": "Xie"}, + "iops": {"en": "IOPS", "pl": "IOPS", "de": "IOPS", "es": "IOPS", "fr": "IOPS", "uk": "IOPS", "zh": "IOPS"}, # Network "total": {"en": "Total", "pl": "Suma", "de": "Gesamt", "es": "Total", "fr": "Total", "uk": "Vsogo", "zh": "Zongji"}, + "errors": {"en": "Err", "pl": "Bld", "de": "Fehl", "es": "Err", "fr": "Err", "uk": "Pom", "zh": "Cuowu"}, + "drops": {"en": "Drop", "pl": "Utr", "de": "Verl", "es": "Perd", "fr": "Perte", "uk": "Vtrat", "zh": "Diubao"}, + # CPU + "user": {"en": "usr", "pl": "uzyt", "de": "Nutz", "es": "usr", "fr": "util", "uk": "koryst", "zh": "yonghu"}, + "sys": {"en": "sys", "pl": "sys", "de": "Sys", "es": "sis", "fr": "sys", "uk": "sys", "zh": "xitong"}, + "io_wait": {"en": "iow", "pl": "iow", "de": "IOW", "es": "iow", "fr": "iow", "uk": "iow", "zh": "iow"}, + "steal": {"en": "stl", "pl": "stl", "de": "Stl", "es": "stl", "fr": "stl", "uk": "stl", "zh": "stl"}, + "idle": {"en": "idl", "pl": "idl", "de": "Idl", "es": "idl", "fr": "idl", "uk": "idl", "zh": "kongx"}, + # Memory + "buffers": {"en": "Buf", "pl": "Buf", "de": "Puf", "es": "Buf", "fr": "Buf", "uk": "Buf", "zh": "Huanch"}, + "cached": {"en": "Cache", "pl": "Cache", "de": "Cache", "es": "Cache", "fr": "Cache", "uk": "Kesh", "zh": "Huancun"}, + "available": {"en": "Avail", "pl": "Dost.", "de": "Verf.", "es": "Disp.", "fr": "Disp.", "uk": "Dost.", "zh": "Keyong"}, + # System info + "ctx_sw": {"en": "CtxSw", "pl": "CtxSw", "de": "KtxW", "es": "CtxSw", "fr": "CtxSw", "uk": "CtxSw", "zh": "CtxSw"}, + "interrupts": {"en": "IRQs", "pl": "IRQ", "de": "IRQs", "es": "IRQs", "fr": "IRQs", "uk": "IRQ", "zh": "Zhongduan"}, + "threads": {"en": "Threads", "pl": "Watki", "de": "Threads", "es": "Hilos", "fr": "Threads", "uk": "Potoki", "zh": "Xiancheng"}, + "fds": {"en": "FDs", "pl": "FD", "de": "FDs", "es": "FDs", "fr": "FDs", "uk": "FD", "zh": "FDs"}, # Process table "filter": {"en": "filter", "pl": "filtr", "de": "Filter", "es": "filtro", "fr": "filtre", "uk": "filtr", "zh": "guolv"}, "name": {"en": "NAME", "pl": "NAZWA", "de": "NAME", "es": "NOMBRE", "fr": "NOM", "uk": "NAZVA", "zh": "MINGCH"}, @@ -69,6 +92,7 @@ TRANSLATIONS = { "hint_details": {"en": "details", "pl": "szczeg.", "de": "Details", "es": "detalle", "fr": "details", "uk": "detal.", "zh": "xiangxi"}, "hint_nice": {"en": "nice", "pl": "priorytet", "de": "Priorit.", "es": "prior.", "fr": "prior.", "uk": "prior.", "zh": "youxian"}, "hint_quit": {"en": "quit", "pl": "wyjscie", "de": "Ende", "es": "salir", "fr": "quitter", "uk": "vyhid", "zh": "tuichu"}, + "hint_tree": {"en": "tree", "pl": "drzewo", "de": "Baum", "es": "arbol", "fr": "arbre", "uk": "derevo", "zh": "shu"}, # Kill menu "send_signal_to": {"en": "Send signal to", "pl": "Wyslij sygnal do", "de": "Signal senden an", "es": "Enviar senal a", "fr": "Envoyer signal a", "uk": "Nadislaty sygnal", "zh": "Fasong xinhao"}, "send": {"en": "send", "pl": "wyslij", "de": "senden", "es": "enviar", "fr": "envoyer", "uk": "nadis.", "zh": "fasong"}, @@ -105,6 +129,7 @@ TRANSLATIONS = { "help_sort_mem": {"en": "Sort by MEM%", "pl": "Sortuj wg MEM%", "de": "Nach MEM% sortieren", "es": "Ordenar por MEM%", "fr": "Trier par MEM%", "uk": "Sort. za MEM%", "zh": "An MEM% paixu"}, "help_sort_pid": {"en": "Sort by PID", "pl": "Sortuj wg PID", "de": "Nach PID sortieren", "es": "Ordenar por PID", "fr": "Trier par PID", "uk": "Sort. za PID", "zh": "An PID paixu"}, "help_sort_name": {"en": "Sort by name", "pl": "Sortuj wg nazwy", "de": "Nach Name sortieren", "es": "Ordenar por nombre", "fr": "Trier par nom", "uk": "Sort. za nazvoyu", "zh": "An mingcheng paixu"}, + "help_sort_io": {"en": "Sort by IO", "pl": "Sortuj wg IO", "de": "Nach IO sortieren", "es": "Ordenar por IO", "fr": "Trier par IO", "uk": "Sort. za IO", "zh": "An IO paixu"}, "help_reverse": {"en": "Reverse sort order", "pl": "Odwroc sortowanie", "de": "Sortierung umkehren", "es": "Invertir orden", "fr": "Inverser le tri", "uk": "Zvorotne sortuvannya", "zh": "Fanzhuan paixu"}, "help_proc": {"en": "PROCESS MGMT", "pl": "ZARZ. PROCESAMI", "de": "PROZESSVERWALT.", "es": "GEST. PROCESOS", "fr": "GEST. PROCESSUS", "uk": "KER. PROCESAMY", "zh": "JINCHENG GUANLI"}, "help_sigterm": {"en": "Send SIGTERM to process", "pl": "Wyslij SIGTERM", "de": "SIGTERM senden", "es": "Enviar SIGTERM", "fr": "Envoyer SIGTERM", "uk": "Nadislaty SIGTERM", "zh": "Fasong SIGTERM"}, @@ -114,6 +139,7 @@ TRANSLATIONS = { "help_nice_neg": {"en": "Renice process (negative)", "pl": "Zmien priorytet (ujemny)", "de": "Prioritaet aendern (neg)", "es": "Cambiar prioridad (neg)", "fr": "Changer priorite (neg)", "uk": "Zminyty priorytet (vid'yem.)", "zh": "Gaibian youxianji (fu)"}, "help_details": {"en": "Process details", "pl": "Szczegoly procesu", "de": "Prozessdetails", "es": "Detalles proceso", "fr": "Details processus", "uk": "Detali procesu", "zh": "Jincheng xiangxi"}, "help_filter": {"en": "Filter processes", "pl": "Filtruj procesy", "de": "Prozesse filtern", "es": "Filtrar procesos", "fr": "Filtrer processus", "uk": "Filtruvannya procesiv", "zh": "Guolv jincheng"}, + "help_tree": {"en": "Toggle tree view", "pl": "Widok drzewa", "de": "Baumansicht", "es": "Vista arbol", "fr": "Vue arbre", "uk": "Vyd dereva", "zh": "Shu shitu"}, "help_general": {"en": "GENERAL", "pl": "OGOLNE", "de": "ALLGEMEIN", "es": "GENERAL", "fr": "GENERAL", "uk": "ZAGAL'NE", "zh": "TONGYONG"}, "help_help": {"en": "Toggle this help", "pl": "Pokaz/ukryj pomoc", "de": "Hilfe ein/aus", "es": "Mostrar/ocultar ayuda", "fr": "Afficher/masquer aide", "uk": "Pokaz./skhov. dovidku", "zh": "Xianshi/yincang bangzhu"}, "help_lang": {"en": "Switch language", "pl": "Zmien jezyk", "de": "Sprache wechseln", "es": "Cambiar idioma", "fr": "Changer langue", "uk": "Zminyty movu", "zh": "Qiehuan yuyan"}, @@ -144,6 +170,7 @@ def get_help_lines(): ("m", t("help_sort_mem")), ("p", t("help_sort_pid")), ("n", t("help_sort_name")), + ("i", t("help_sort_io")), ("r", t("help_reverse")), ("", ""), (t("help_proc"), ""), @@ -154,6 +181,7 @@ def get_help_lines(): ("- / F7", t("help_nice_neg")), ("d / F5 / Enter", t("help_details")), ("/ / f", t("help_filter")), + ("t", t("help_tree")), ("", ""), (t("help_general"), ""), ("h / F1 / ?", t("help_help")), @@ -161,8 +189,9 @@ def get_help_lines(): ("q / Q / Esc", t("help_quit")), ] + # History length for sparklines -HIST_LEN = 60 +HIST_LEN = 80 # Color pair IDs C_NORMAL = 0 @@ -176,6 +205,16 @@ C_HEADER = 7 C_ACCENT = 8 C_DIM = 9 C_BAR_BG = 10 +C_SELECT = 11 +C_POPUP_BG = 12 +C_POPUP_HL = 13 +C_CYAN_DIM = 14 +C_BLUE_BOLD = 15 +C_MAGENTA_DIM = 16 +C_GREEN_BOLD = 17 +C_YELLOW_BOLD = 18 +C_RED_BOLD = 19 +C_WHITE_BOLD = 20 def init_colors(): @@ -187,13 +226,20 @@ def init_colors(): curses.init_pair(C_HIGH, curses.COLOR_RED, -1) curses.init_pair(C_CRIT, curses.COLOR_WHITE, curses.COLOR_RED) curses.init_pair(C_BORDER, curses.COLOR_BLUE, -1) - curses.init_pair(C_HEADER, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(C_HEADER, curses.COLOR_WHITE, curses.COLOR_BLUE) curses.init_pair(C_ACCENT, curses.COLOR_MAGENTA, -1) curses.init_pair(C_DIM, curses.COLOR_WHITE, -1) curses.init_pair(C_BAR_BG, curses.COLOR_BLACK, -1) curses.init_pair(C_SELECT, curses.COLOR_BLACK, curses.COLOR_WHITE) curses.init_pair(C_POPUP_BG, curses.COLOR_WHITE, curses.COLOR_BLUE) curses.init_pair(C_POPUP_HL, curses.COLOR_YELLOW, curses.COLOR_BLUE) + curses.init_pair(C_CYAN_DIM, curses.COLOR_CYAN, -1) + curses.init_pair(C_BLUE_BOLD, curses.COLOR_BLUE, -1) + curses.init_pair(C_MAGENTA_DIM, curses.COLOR_MAGENTA, -1) + curses.init_pair(C_GREEN_BOLD, curses.COLOR_GREEN, -1) + curses.init_pair(C_YELLOW_BOLD, curses.COLOR_YELLOW, -1) + curses.init_pair(C_RED_BOLD, curses.COLOR_RED, -1) + curses.init_pair(C_WHITE_BOLD, curses.COLOR_WHITE, -1) def pct_color(pct): @@ -222,24 +268,62 @@ def fmt_bytes_short(n): return f"{n:.1f}P" -def draw_bar(win, y, x, width, pct, label="", show_pct=True): - """Draw a colored progress bar.""" +def fmt_bytes_compact(n): + for unit in ("B", "K", "M", "G", "T"): + if abs(n) < 1024: + if n < 10: + return f"{n:.1f}{unit}" + return f"{n:.0f}{unit}" + n /= 1024 + return f"{n:.0f}P" + + +def fmt_rate(n): + """Format bytes/s in compact form.""" + return fmt_bytes_compact(n) + "/s" + + +def fmt_count(n): + """Format large numbers compactly.""" + if n < 1000: + return str(n) + elif n < 1_000_000: + return f"{n/1000:.1f}K" + elif n < 1_000_000_000: + return f"{n/1_000_000:.1f}M" + return f"{n/1_000_000_000:.1f}G" + + +def draw_gradient_bar(win, y, x, width, pct, label="", show_pct=True): + """Draw a gradient progress bar with smooth color transitions.""" if width < 4: return filled = int(width * pct / 100) filled = min(filled, width) - col = pct_color(pct) try: - # Filled part - bar_str = "\u2588" * filled + # Multi-segment gradient: green -> yellow -> red + for i in range(filled): + seg_pct = (i / max(width - 1, 1)) * 100 + if seg_pct < 40: + col = C_LOW + elif seg_pct < 65: + col = C_MED + elif seg_pct < 85: + col = C_HIGH + else: + col = C_CRIT + win.addstr(y, x + i, "\u2588", curses.color_pair(col)) + + # Empty part with subtle dots empty_str = "\u2591" * (width - filled) - win.addstr(y, x, bar_str, curses.color_pair(col)) win.addstr(y, x + filled, empty_str, curses.color_pair(C_DIM) | curses.A_DIM) + if show_pct: pct_str = f"{pct:5.1f}%" px = x + width - len(pct_str) if px > x: + col = pct_color(pct) win.addstr(y, px, pct_str, curses.color_pair(col) | curses.A_BOLD) if label: win.addstr(y, x + 1, label[:width - 8], curses.color_pair(C_NORMAL) | curses.A_BOLD) @@ -247,19 +331,75 @@ def draw_bar(win, y, x, width, pct, label="", show_pct=True): pass -def draw_sparkline(win, y, x, width, data, max_val=100): - """Draw a sparkline from historical data.""" +def draw_stacked_bar(win, y, x, width, segments): + """Draw a stacked bar with multiple colored segments. + segments: list of (value_pct, color_pair, char) + """ + if width < 2: + return + try: + pos = 0 + for val_pct, col, ch in segments: + seg_w = int(width * val_pct / 100) + if seg_w <= 0: + continue + seg_w = min(seg_w, width - pos) + win.addstr(y, x + pos, ch * seg_w, curses.color_pair(col)) + pos += seg_w + if pos < width: + win.addstr(y, x + pos, "\u2591" * (width - pos), curses.color_pair(C_DIM) | curses.A_DIM) + except curses.error: + pass + + +def draw_braille_sparkline(win, y, x, width, data, max_val=100, color=None): + """Draw a sparkline using braille characters for 2x vertical resolution.""" if width < 2 or not data: return - chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588" + # Braille dots: bottom dot = 0x2840, top dot = 0x2880 + # We use pairs of rows in a single braille char samples = list(data)[-width:] if max_val == 0: max_val = 1 try: for i, val in enumerate(samples): - idx = int(min(val / max_val, 1.0) * (len(chars) - 1)) - col = pct_color(val / max_val * 100 if max_val else 0) - win.addstr(y, x + i, chars[idx], curses.color_pair(col)) + ratio = min(val / max_val, 1.0) + # Map to 0-8 levels using braille block elements + level = int(ratio * 8) + # Use vertical block elements for better resolution + blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588" + ch = blocks[min(level, 8)] + col = color or pct_color(ratio * 100) + win.addstr(y, x + i, ch, curses.color_pair(col)) + except curses.error: + pass + + +def draw_dual_sparkline(win, y, x, width, data1, data2, max_val=None, c1=C_LOW, c2=C_MED): + """Draw two sparklines interleaved (RX above TX).""" + if width < 2 or (not data1 and not data2): + return + s1 = list(data1)[-width:] + s2 = list(data2)[-width:] + if max_val is None: + max_val = max(max(s1, default=1), max(s2, default=1), 1) + top_blocks = " \u2580\u2580\u2580\u2588" # upper half blocks + bot_blocks = " \u2584\u2584\u2584\u2588" # lower half blocks + try: + for i in range(min(width, max(len(s1), len(s2)))): + v1 = s1[i] if i < len(s1) else 0 + v2 = s2[i] if i < len(s2) else 0 + r1 = min(v1 / max_val, 1.0) if max_val else 0 + r2 = min(v2 / max_val, 1.0) if max_val else 0 + # Use top half for data1, bottom for data2 + if r1 > 0.5 and r2 > 0.5: + win.addstr(y, x + i, "\u2588", curses.color_pair(c1)) + elif r1 > 0.2: + win.addstr(y, x + i, "\u2580", curses.color_pair(c1)) + elif r2 > 0.2: + win.addstr(y, x + i, "\u2584", curses.color_pair(c2)) + else: + win.addstr(y, x + i, "\u2581", curses.color_pair(C_DIM) | curses.A_DIM) except curses.error: pass @@ -267,7 +407,6 @@ def draw_sparkline(win, y, x, width, data, max_val=100): def draw_box(win, y, x, h, w, title=""): """Draw a bordered box with optional title.""" try: - # Corners win.addstr(y, x, "\u250c", curses.color_pair(C_BORDER)) win.addstr(y, x + w - 1, "\u2510", curses.color_pair(C_BORDER)) win.addstr(y + h - 1, x, "\u2514", curses.color_pair(C_BORDER)) @@ -275,25 +414,22 @@ def draw_box(win, y, x, h, w, title=""): win.addstr(y + h - 1, x + w - 1, "\u2518", curses.color_pair(C_BORDER)) except curses.error: pass - # Horizontal lines for cx in range(x + 1, x + w - 1): win.addstr(y, cx, "\u2500", curses.color_pair(C_BORDER)) try: win.addstr(y + h - 1, cx, "\u2500", curses.color_pair(C_BORDER)) except curses.error: pass - # Vertical lines for cy in range(y + 1, y + h - 1): win.addstr(cy, x, "\u2502", curses.color_pair(C_BORDER)) try: win.addstr(cy, x + w - 1, "\u2502", curses.color_pair(C_BORDER)) except curses.error: pass - # Title if title: - t = f" {title} " + tt = f" {title} " tx = x + 2 - win.addstr(y, tx, t, curses.color_pair(C_TITLE) | curses.A_BOLD) + win.addstr(y, tx, tt, curses.color_pair(C_TITLE) | curses.A_BOLD) except curses.error: pass @@ -312,12 +448,20 @@ def safe_addstr(win, y, x, text, attr=0): # ─── Data collectors ──────────────────────────────────────────────── class SystemData: + # How often to run expensive operations (in update cycles) + SLOW_INTERVAL = 5 # every 5 seconds + def __init__(self): self.cpu_history = deque(maxlen=HIST_LEN) self.mem_history = deque(maxlen=HIST_LEN) self.net_rx_history = deque(maxlen=HIST_LEN) self.net_tx_history = deque(maxlen=HIST_LEN) self.per_cpu_history = [] + self.gpu_util_history = [] + self.gpu_mem_history = [] + self.disk_read_history = deque(maxlen=HIST_LEN) + self._tick = 0 # cycle counter for slow ops + self.disk_write_history = deque(maxlen=HIST_LEN) self.prev_net = psutil.net_io_counters() self.prev_disk_io = psutil.disk_io_counters() self.prev_time = time.time() @@ -325,8 +469,18 @@ class SystemData: self.net_tx_rate = 0 self.disk_read_rate = 0 self.disk_write_rate = 0 - self.process_sort = "cpu" # cpu, mem, pid, name + self.disk_read_iops = 0 + self.disk_write_iops = 0 + self.process_sort = "cpu" # cpu, mem, pid, name, io self.process_sort_reverse = True + self.prev_ctx_switches = 0 + self.prev_interrupts = 0 + self.ctx_switch_rate = 0 + self.interrupt_rate = 0 + self.per_iface_rates = {} + self.prev_per_iface = {} + self.per_iface_rx_history = {} + self.per_iface_tx_history = {} def update(self): now = time.time() @@ -334,13 +488,14 @@ class SystemData: if dt < 0.01: dt = 0.01 self.prev_time = now + self._tick += 1 + slow_tick = (self._tick % self.SLOW_INTERVAL == 0) - # CPU + # CPU overall + per-core self.cpu_pct = psutil.cpu_percent(interval=0) self.per_cpu = psutil.cpu_percent(interval=0, percpu=True) self.cpu_history.append(self.cpu_pct) - # Initialize per-cpu histories while len(self.per_cpu_history) < len(self.per_cpu): self.per_cpu_history.append(deque(maxlen=HIST_LEN)) for i, pct in enumerate(self.per_cpu): @@ -351,56 +506,125 @@ class SystemData: self.cpu_count = psutil.cpu_count() self.cpu_count_phys = psutil.cpu_count(logical=False) + # CPU times breakdown + try: + ct = psutil.cpu_times_percent(interval=0) + self.cpu_user = ct.user + getattr(ct, 'nice', 0) + self.cpu_system = ct.system + self.cpu_iowait = getattr(ct, 'iowait', 0) + self.cpu_steal = getattr(ct, 'steal', 0) + self.cpu_idle = ct.idle + except Exception: + self.cpu_user = self.cpu_pct + self.cpu_system = 0 + self.cpu_iowait = 0 + self.cpu_steal = 0 + self.cpu_idle = 100 - self.cpu_pct + + # Context switches and interrupts + try: + stats = psutil.cpu_stats() + if self.prev_ctx_switches: + self.ctx_switch_rate = (stats.ctx_switches - self.prev_ctx_switches) / dt + self.interrupt_rate = (stats.interrupts - self.prev_interrupts) / dt + self.prev_ctx_switches = stats.ctx_switches + self.prev_interrupts = stats.interrupts + self.soft_interrupts = getattr(stats, 'soft_interrupts', 0) + self.syscalls = getattr(stats, 'syscalls', 0) + except Exception: + self.ctx_switch_rate = 0 + self.interrupt_rate = 0 + # Memory self.mem = psutil.virtual_memory() self.swap = psutil.swap_memory() self.mem_history.append(self.mem.percent) - # Disk - self.partitions = [] - for p in psutil.disk_partitions(all=False): - try: - usage = psutil.disk_usage(p.mountpoint) - self.partitions.append((p, usage)) - except (PermissionError, OSError): - pass + # Disk (partitions don't change often) + if slow_tick or not hasattr(self, 'partitions'): + self.partitions = [] + for p in psutil.disk_partitions(all=False): + try: + usage = psutil.disk_usage(p.mountpoint) + self.partitions.append((p, usage)) + except (PermissionError, OSError): + pass - # Disk IO + # Disk IO with IOPS try: dio = psutil.disk_io_counters() if dio and self.prev_disk_io: self.disk_read_rate = (dio.read_bytes - self.prev_disk_io.read_bytes) / dt self.disk_write_rate = (dio.write_bytes - self.prev_disk_io.write_bytes) / dt + self.disk_read_iops = (dio.read_count - self.prev_disk_io.read_count) / dt + self.disk_write_iops = (dio.write_count - self.prev_disk_io.write_count) / dt + self.disk_read_history.append(self.disk_read_rate) + self.disk_write_history.append(self.disk_write_rate) self.prev_disk_io = dio except Exception: pass - # Network + # Network (global) try: net = psutil.net_io_counters() self.net_rx_rate = (net.bytes_recv - self.prev_net.bytes_recv) / dt self.net_tx_rate = (net.bytes_sent - self.prev_net.bytes_sent) / dt self.net_rx_history.append(self.net_rx_rate) self.net_tx_history.append(self.net_tx_rate) + self.net_errors_in = net.errin + self.net_errors_out = net.errout + self.net_drops_in = net.dropin + self.net_drops_out = net.dropout self.prev_net = net + except Exception: + self.net_errors_in = 0 + self.net_errors_out = 0 + self.net_drops_in = 0 + self.net_drops_out = 0 + + # Per-interface network rates + try: + per_iface = psutil.net_io_counters(pernic=True) + for iface, counters in per_iface.items(): + if iface == "lo": + continue + prev = self.prev_per_iface.get(iface) + if prev: + rx = (counters.bytes_recv - prev.bytes_recv) / dt + tx = (counters.bytes_sent - prev.bytes_sent) / dt + self.per_iface_rates[iface] = (rx, tx) + if iface not in self.per_iface_rx_history: + self.per_iface_rx_history[iface] = deque(maxlen=HIST_LEN) + self.per_iface_tx_history[iface] = deque(maxlen=HIST_LEN) + self.per_iface_rx_history[iface].append(rx) + self.per_iface_tx_history[iface].append(tx) + self.prev_per_iface = per_iface except Exception: pass - # Network interfaces self.net_addrs = psutil.net_if_addrs() self.net_stats = psutil.net_if_stats() - # Temperatures - try: - self.temps = psutil.sensors_temperatures() - except Exception: - self.temps = {} + # Network connections count (slow - only every N ticks) + if slow_tick or not hasattr(self, 'net_connections'): + try: + self.net_connections = len(psutil.net_connections(kind='inet')) + except (psutil.AccessDenied, OSError): + self.net_connections = 0 + + # Temperatures (slow - 70ms+) + if slow_tick or not hasattr(self, 'temps'): + try: + self.temps = psutil.sensors_temperatures() + except Exception: + self.temps = {} # Fans - try: - self.fans = psutil.sensors_fans() - except Exception: - self.fans = {} + if slow_tick or not hasattr(self, 'fans'): + try: + self.fans = psutil.sensors_fans() + except Exception: + self.fans = {} # Battery try: @@ -412,19 +636,43 @@ class SystemData: self.boot_time = psutil.boot_time() self.uptime = timedelta(seconds=int(now - self.boot_time)) + # Logged-in users (slow) + if slow_tick or not hasattr(self, 'logged_users'): + try: + self.logged_users = psutil.users() + except Exception: + self.logged_users = [] + # Processes self._update_processes() + # AMD GPU process scan (very slow - only on slow ticks) + if slow_tick: + self._scan_amd_gpu_procs() + # GPU self.gpus = [] self._update_nvidia() self._update_amd() + # GPU history + while len(self.gpu_util_history) < len(self.gpus): + self.gpu_util_history.append(deque(maxlen=HIST_LEN)) + self.gpu_mem_history.append(deque(maxlen=HIST_LEN)) + for i, gpu in enumerate(self.gpus): + if i < len(self.gpu_util_history): + self.gpu_util_history[i].append(gpu["gpu_util"]) + self.gpu_mem_history[i].append(gpu["mem_util"]) + def _update_processes(self): + # io_counters is expensive - only fetch when sorting by IO or on slow ticks + fetch_io = (self.process_sort == "io") or (self._tick % self.SLOW_INTERVAL == 0) + attrs = ["pid", "name", "cpu_percent", "memory_percent", + "status", "username", "num_threads", "memory_info", "ppid"] + if fetch_io: + attrs.append("io_counters") procs = [] - for p in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent", - "status", "username", "num_threads", - "memory_info"]): + for p in psutil.process_iter(attrs): try: info = p.info if info["pid"] == 0: @@ -433,16 +681,36 @@ class SystemData: except (psutil.NoSuchProcess, psutil.AccessDenied): pass + # Stable sort: primary key + PID as tiebreaker to prevent jumping sort_key = { - "cpu": lambda p: p.get("cpu_percent", 0) or 0, - "mem": lambda p: p.get("memory_percent", 0) or 0, - "pid": lambda p: p.get("pid", 0), - "name": lambda p: (p.get("name") or "").lower(), - }.get(self.process_sort, lambda p: p.get("cpu_percent", 0) or 0) + "cpu": lambda p: (round((p.get("cpu_percent", 0) or 0), 0), p.get("pid", 0)), + "mem": lambda p: (round((p.get("memory_percent", 0) or 0), 1), p.get("pid", 0)), + "pid": lambda p: (p.get("pid", 0),), + "name": lambda p: ((p.get("name") or "").lower(), p.get("pid", 0)), + "io": lambda p: ( + (p.get("io_counters").read_bytes + p.get("io_counters").write_bytes) + if p.get("io_counters") else 0, + p.get("pid", 0), + ), + }.get(self.process_sort, lambda p: (p.get("cpu_percent", 0) or 0, p.get("pid", 0))) self.processes = sorted(procs, key=sort_key, reverse=self.process_sort_reverse) self.proc_count = len(procs) self.proc_running = sum(1 for p in procs if p.get("status") == psutil.STATUS_RUNNING) + self.total_threads = sum(p.get("num_threads", 0) or 0 for p in procs) + + # Per-user resource usage + user_stats = {} + for p in procs: + user = p.get("username") or "?" + if user not in user_stats: + user_stats[user] = {"cpu": 0.0, "mem": 0.0, "procs": 0, "threads": 0} + user_stats[user]["cpu"] += (p.get("cpu_percent", 0) or 0) + user_stats[user]["mem"] += (p.get("memory_percent", 0) or 0) + user_stats[user]["procs"] += 1 + user_stats[user]["threads"] += (p.get("num_threads", 0) or 0) + # Sort by CPU descending + self.user_stats = sorted(user_stats.items(), key=lambda x: x[1]["cpu"], reverse=True) def _update_nvidia(self): if not NVIDIA_AVAILABLE: @@ -476,6 +744,10 @@ class SystemData: power = pynvml.nvmlDeviceGetPowerUsage(handle) / 1000 except Exception: power = 0 + try: + power_limit = pynvml.nvmlDeviceGetPowerManagementLimit(handle) / 1000 + except Exception: + power_limit = 0 try: fan = pynvml.nvmlDeviceGetFanSpeed(handle) except Exception: @@ -486,6 +758,62 @@ class SystemData: except Exception: clk_gpu = 0 clk_mem = 0 + try: + clk_gpu_max = pynvml.nvmlDeviceGetMaxClockInfo(handle, pynvml.NVML_CLOCK_GRAPHICS) + except Exception: + clk_gpu_max = 0 + try: + pcie_gen = pynvml.nvmlDeviceGetCurrPcieLinkGeneration(handle) + pcie_width = pynvml.nvmlDeviceGetCurrPcieLinkWidth(handle) + except Exception: + pcie_gen = 0 + pcie_width = 0 + try: + enc_util = pynvml.nvmlDeviceGetEncoderUtilization(handle)[0] + dec_util = pynvml.nvmlDeviceGetDecoderUtilization(handle)[0] + except Exception: + enc_util = 0 + dec_util = 0 + + # GPU processes + gpu_procs = [] + try: + compute_procs = pynvml.nvmlDeviceGetComputeRunningProcesses(handle) + for p in compute_procs: + pid = p.pid + mem = getattr(p, 'usedGpuMemory', 0) or 0 + try: + pname = psutil.Process(pid).name() + except Exception: + pname = f"pid:{pid}" + gpu_procs.append({"pid": pid, "name": pname, "mem": mem, "type": "C"}) + except Exception: + pass + try: + graphics_procs = pynvml.nvmlDeviceGetGraphicsRunningProcesses(handle) + existing_pids = {p["pid"] for p in gpu_procs} + for p in graphics_procs: + pid = p.pid + mem = getattr(p, 'usedGpuMemory', 0) or 0 + if pid in existing_pids: + for ep in gpu_procs: + if ep["pid"] == pid: + ep["type"] = "C+G" + ep["mem"] = max(ep["mem"], mem) + break + else: + try: + pname = psutil.Process(pid).name() + except Exception: + pname = f"pid:{pid}" + gpu_procs.append({"pid": pid, "name": pname, "mem": mem, "type": "G"}) + except Exception: + pass + gpu_procs.sort(key=lambda p: p["mem"], reverse=True) + + # VRAM breakdown: used by processes vs other (driver/reserved) + procs_vram = sum(p["mem"] for p in gpu_procs) + vram_other = max(0, mem_used - procs_vram) if mem_used else 0 self.gpus.append({ "vendor": "NVIDIA", @@ -496,9 +824,18 @@ class SystemData: "mem_total": mem_total, "temp": temp, "power": power, + "power_limit": power_limit, "fan": fan, "clk_gpu": clk_gpu, "clk_mem": clk_mem, + "clk_gpu_max": clk_gpu_max, + "pcie_gen": pcie_gen, + "pcie_width": pcie_width, + "enc_util": enc_util, + "dec_util": dec_util, + "processes": gpu_procs, + "vram_procs": procs_vram, + "vram_other": vram_other, }) except Exception: pass @@ -548,6 +885,11 @@ class SystemData: clk_gpu = read_val(os.path.join(base, "pp_dpm_sclk")) clk_mem = read_val(os.path.join(base, "pp_dpm_mclk")) + # AMD: no fast process API, use cached results + gpu_procs = getattr(self, '_amd_gpu_procs', []) + procs_vram = sum(p["mem"] for p in gpu_procs) + vram_other = max(0, mem_used - procs_vram) if mem_used else 0 + self.gpus.append({ "vendor": "AMD", "name": name, @@ -557,20 +899,64 @@ class SystemData: "mem_total": mem_total, "temp": temp, "power": power, + "power_limit": 0, "fan": fan_pct, "clk_gpu": clk_gpu, "clk_mem": clk_mem, + "clk_gpu_max": 0, + "pcie_gen": 0, + "pcie_width": 0, + "enc_util": 0, + "dec_util": 0, + "processes": gpu_procs, + "vram_procs": procs_vram, + "vram_other": vram_other, }) except Exception: pass + def _scan_amd_gpu_procs(self): + """Scan /proc fdinfo for AMD GPU processes. Expensive - call rarely.""" + if not os.path.exists(AMD_GPU_PATH): + self._amd_gpu_procs = [] + return + gpu_procs = [] + try: + for pid_dir in os.listdir("/proc"): + if not pid_dir.isdigit(): + continue + fdinfo_dir = f"/proc/{pid_dir}/fdinfo" + try: + for fd in os.listdir(fdinfo_dir): + try: + with open(os.path.join(fdinfo_dir, fd)) as f: + content = f.read(4096) + if "drm-memory-vram:" not in content: + continue + vram = 0 + for ln in content.splitlines(): + if ln.startswith("drm-memory-vram:"): + vram = int(ln.split(":")[1].strip().split()[0]) * 1024 + if vram > 0: + pid = int(pid_dir) + if not any(p["pid"] == pid for p in gpu_procs): + try: + pname = psutil.Process(pid).name() + except Exception: + pname = f"pid:{pid}" + gpu_procs.append({"pid": pid, "name": pname, "mem": vram, "type": "G"}) + except Exception: + continue + except Exception: + continue + except Exception: + pass + gpu_procs.sort(key=lambda p: p["mem"], reverse=True) + self._amd_gpu_procs = gpu_procs + # ─── Rendering ────────────────────────────────────────────────────── -C_SELECT = 11 -C_POPUP_BG = 12 -C_POPUP_HL = 13 - class ProcessManager: """Interactive process management: kill, nice, filter, details.""" @@ -588,6 +974,7 @@ class ProcessManager: def __init__(self): self.selected_idx = 0 + self.selected_pid = None # track selection by PID self.filter_text = "" self.filter_mode = False self.show_kill_menu = False @@ -597,6 +984,7 @@ class ProcessManager: self.nice_value = "" self.status_msg = "" self.status_time = 0 + self.tree_mode = False def get_selected_proc(self, processes): filtered = self._filtered(processes) @@ -614,7 +1002,45 @@ class ProcessManager: ft in (p.get("username") or "").lower()] def filtered_processes(self, processes): - return self._filtered(processes) + procs = self._filtered(processes) + if self.tree_mode: + procs = self._build_tree(procs) + # Restore selection by PID after re-sort + if self.selected_pid is not None: + for i, p in enumerate(procs): + if p.get("pid") == self.selected_pid: + self.selected_idx = i + break + # Clamp and update tracked PID + if procs: + self.selected_idx = max(0, min(self.selected_idx, len(procs) - 1)) + self.selected_pid = procs[self.selected_idx].get("pid") + return procs + + def _build_tree(self, processes): + """Build a process tree with indentation markers.""" + by_pid = {p["pid"]: p for p in processes} + children = {} + roots = [] + for p in processes: + ppid = p.get("ppid", 0) + if ppid in by_pid and ppid != p["pid"]: + children.setdefault(ppid, []).append(p) + else: + roots.append(p) + + result = [] + def walk(proc, depth): + proc = dict(proc) + proc["_tree_depth"] = depth + result.append(proc) + for child in sorted(children.get(proc["pid"], []), + key=lambda p: p.get("cpu_percent", 0) or 0, reverse=True): + walk(child, depth + 1) + + for root in sorted(roots, key=lambda p: p.get("cpu_percent", 0) or 0, reverse=True): + walk(root, 0) + return result def kill_selected(self, processes, sig): proc = self.get_selected_proc(processes) @@ -677,16 +1103,22 @@ class ProcessManager: mem = p.memory_info() info["rss"] = fmt_bytes_short(mem.rss) info["vms"] = fmt_bytes_short(mem.vms) + info["shared"] = fmt_bytes_short(getattr(mem, 'shared', 0)) except Exception: info["rss"] = "?" info["vms"] = "?" + info["shared"] = "?" try: io = p.io_counters() info["io_read"] = fmt_bytes_short(io.read_bytes) info["io_write"] = fmt_bytes_short(io.write_bytes) + info["io_read_ops"] = fmt_count(io.read_count) + info["io_write_ops"] = fmt_count(io.write_count) except Exception: info["io_read"] = "?" info["io_write"] = "?" + info["io_read_ops"] = "?" + info["io_write_ops"] = "?" try: info["num_fds"] = p.num_fds() except Exception: @@ -700,6 +1132,10 @@ class ProcessManager: info["cwd"] = p.cwd() except Exception: info["cwd"] = "?" + try: + info["cpu_affinity"] = len(p.cpu_affinity()) + except Exception: + info["cpu_affinity"] = "?" return info except (psutil.NoSuchProcess, psutil.AccessDenied): return None @@ -733,31 +1169,68 @@ class Renderer: y = 0 y = self._draw_header(y, w) - y += 1 - # Split layout: left panel (CPU+MEM+SWAP), right panel (NET+DISK) - left_w = w // 2 - right_w = w - left_w - - panel_y = y - left_y = self._draw_cpu(panel_y, 0, left_w) - left_y = self._draw_memory(left_y, 0, left_w) - - right_y = self._draw_network(panel_y, left_w, right_w) - right_y = self._draw_disk(right_y, left_w, right_w) - - y = max(left_y, right_y) - - # GPU (full width) - if self.data.gpus: - y = self._draw_gpu(y, 0, w) - - # Temperatures (full width) - if self.data.temps: - y = self._draw_temps(y, 0, w) + # Adaptive layout based on terminal width + if w >= 140: + # Wide: three columns + col1_w = w // 3 + col2_w = w // 3 + col3_w = w - col1_w - col2_w + panel_y = y + y1 = self._draw_cpu(panel_y, 0, col1_w) + y1 = self._draw_memory(y1, 0, col1_w) + y2 = self._draw_network(panel_y, col1_w, col2_w) + y2 = self._draw_disk(y2, col1_w, col2_w) + y3 = panel_y + if self.data.gpus: + y3 = self._draw_gpu(y3, col1_w + col2_w, col3_w) + y3 = self._draw_system_info(y3, col1_w + col2_w, col3_w) + if self.data.temps: + y3 = self._draw_temps(y3, col1_w + col2_w, col3_w) + if self.data.battery: + y3 = self._draw_battery(y3, col1_w + col2_w, col3_w) + y = max(y1, y2, y3) + y = self._draw_users(y, 0, w) + elif w >= 80: + # Normal: two columns + left_w = w // 2 + right_w = w - left_w + panel_y = y + left_y = self._draw_cpu(panel_y, 0, left_w) + left_y = self._draw_memory(left_y, 0, left_w) + right_y = self._draw_network(panel_y, left_w, right_w) + right_y = self._draw_disk(right_y, left_w, right_w) + y = max(left_y, right_y) + if self.data.gpus: + y = self._draw_gpu(y, 0, w) + # System info + users side by side + si_w = w // 2 + us_w = w - si_w + si_y = self._draw_system_info(y, 0, si_w) + us_y = self._draw_users(y, si_w, us_w) + y = max(si_y, us_y) + if self.data.temps: + y = self._draw_temps(y, 0, w) + if self.data.battery: + y = self._draw_battery(y, 0, w) + else: + # Narrow: single column + y = self._draw_cpu(y, 0, w) + y = self._draw_memory(y, 0, w) + y = self._draw_network(y, 0, w) + y = self._draw_disk(y, 0, w) + if self.data.gpus: + y = self._draw_gpu(y, 0, w) + y = self._draw_system_info(y, 0, w) + y = self._draw_users(y, 0, w) + if self.data.temps: + y = self._draw_temps(y, 0, w) + if self.data.battery: + y = self._draw_battery(y, 0, w) # Processes (rest of screen) - self._draw_processes(y, 0, w, h - y) + if h - y >= 4: + self._draw_processes(y, 0, w, h - y) # Help overlay if self.show_help: @@ -768,10 +1241,11 @@ class Renderer: def _draw_header(self, y, w): d = self.data hostname = os.uname().nodename - kernel = os.uname().release - header = f" ENTROPYMON | {hostname} | Linux {kernel} | {t('up')}: {d.uptime} " - header += f"| {t('load')}: {d.load_avg[0]:.2f} {d.load_avg[1]:.2f} {d.load_avg[2]:.2f} " - header += f"| {t('procs')}: {d.proc_count} ({d.proc_running} {t('run')}) " + kernel = os.uname().release.split("-")[0] + # Compact header + header = f" ENTROPYMON | {hostname} | {kernel} | {t('up')}: {d.uptime}" + header += f" | {t('load')}: {d.load_avg[0]:.2f} {d.load_avg[1]:.2f} {d.load_avg[2]:.2f}" + header += f" | {t('procs')}: {d.proc_count} ({d.proc_running} {t('run')})" brand = " Electric Entropy Lab " header = header.ljust(w - len(brand))[:w - len(brand)] safe_addstr(self.scr, y, 0, header, curses.color_pair(C_HEADER) | curses.A_BOLD) @@ -786,21 +1260,42 @@ class Renderer: freq_str = f" {d.cpu_freq.current:.0f}MHz" title = f"CPU [{d.cpu_count_phys}C/{d.cpu_count}T]{freq_str}" - # Calculate height: 1 header + overall bar + sparkline + per-core bars cores_per_col = (ncpu + 1) // 2 - box_h = 2 + 1 + 1 + cores_per_col + 1 # border + overall + spark + cores + border + box_h = 2 + 1 + 1 + 1 + cores_per_col + 1 # border + breakdown + overall bar + spark + cores + border draw_box(self.scr, y, x, box_h, w, title) iy = y + 1 bar_w = w - 4 - # Overall CPU bar - safe_addstr(self.scr, iy, x + 2, "ALL", curses.color_pair(C_ACCENT) | curses.A_BOLD) - draw_bar(self.scr, iy, x + 6, bar_w - 4, d.cpu_pct) + # CPU time breakdown as stacked bar + segments = [] + if d.cpu_user > 0.5: + segments.append((d.cpu_user, C_LOW, "\u2588")) + if d.cpu_system > 0.5: + segments.append((d.cpu_system, C_MED, "\u2588")) + if d.cpu_iowait > 0.5: + segments.append((d.cpu_iowait, C_HIGH, "\u2588")) + if d.cpu_steal > 0.5: + segments.append((d.cpu_steal, C_CRIT, "\u2588")) + + # Label for breakdown + breakdown = f"{t('user')}:{d.cpu_user:.0f}% {t('sys')}:{d.cpu_system:.0f}%" + if d.cpu_iowait > 0: + breakdown += f" {t('io_wait')}:{d.cpu_iowait:.0f}%" + if d.cpu_steal > 0: + breakdown += f" {t('steal')}:{d.cpu_steal:.0f}%" + breakdown += f" {t('idle')}:{d.cpu_idle:.0f}%" + safe_addstr(self.scr, iy, x + 2, breakdown[:bar_w], + curses.color_pair(C_DIM)) iy += 1 - # Sparkline - draw_sparkline(self.scr, iy, x + 2, bar_w, d.cpu_history) + # Overall gradient bar + safe_addstr(self.scr, iy, x + 2, "ALL", curses.color_pair(C_ACCENT) | curses.A_BOLD) + draw_gradient_bar(self.scr, iy, x + 6, bar_w - 4, d.cpu_pct) + iy += 1 + + # Braille sparkline + draw_braille_sparkline(self.scr, iy, x + 2, bar_w, d.cpu_history) iy += 1 # Per-core bars (two columns) @@ -812,142 +1307,261 @@ class Renderer: cy = iy + row label = f"{i:2d}" safe_addstr(self.scr, cy, cx, label, curses.color_pair(C_DIM)) - draw_bar(self.scr, cy, cx + 3, col_w - 4, pct, show_pct=True) + draw_gradient_bar(self.scr, cy, cx + 3, col_w - 4, pct, show_pct=True) return y + box_h def _draw_memory(self, y, x, w): d = self.data - box_h = 5 + box_h = 7 draw_box(self.scr, y, x, box_h, w, t("memory")) bar_w = w - 4 iy = y + 1 - # RAM + # RAM main bar ram_label = f"RAM {fmt_bytes_short(d.mem.used)}/{fmt_bytes_short(d.mem.total)}" safe_addstr(self.scr, iy, x + 2, ram_label, curses.color_pair(C_ACCENT)) iy += 1 - draw_bar(self.scr, iy, x + 2, bar_w, d.mem.percent) + draw_gradient_bar(self.scr, iy, x + 2, bar_w, d.mem.percent) + iy += 1 + + # Memory breakdown + buffers = getattr(d.mem, 'buffers', 0) + cached = getattr(d.mem, 'cached', 0) + available = d.mem.available + breakdown = f"{t('buffers')}:{fmt_bytes_compact(buffers)} {t('cached')}:{fmt_bytes_compact(cached)} {t('available')}:{fmt_bytes_compact(available)}" + safe_addstr(self.scr, iy, x + 2, breakdown[:bar_w], curses.color_pair(C_DIM)) + iy += 1 + + # Memory sparkline + draw_braille_sparkline(self.scr, iy, x + 2, bar_w, d.mem_history) iy += 1 # Swap swap_label = f"SWP {fmt_bytes_short(d.swap.used)}/{fmt_bytes_short(d.swap.total)}" safe_addstr(self.scr, iy, x + 2, swap_label, curses.color_pair(C_ACCENT)) - if d.swap.total > 0: - # Show swap bar inline sw_bar_x = x + 2 + len(swap_label) + 1 sw_bar_w = w - 4 - len(swap_label) - 1 if sw_bar_w > 5: - draw_bar(self.scr, iy, sw_bar_x, sw_bar_w, d.swap.percent) + draw_gradient_bar(self.scr, iy, sw_bar_x, sw_bar_w, d.swap.percent) return y + box_h def _draw_network(self, y, x, w): d = self.data - box_h = 6 + # Count active interfaces for dynamic height + active_ifaces = [(name, stats) for name, stats in d.net_stats.items() + if stats.isup and name != "lo"] + iface_lines = min(len(active_ifaces), 4) + box_h = 7 + iface_lines draw_box(self.scr, y, x, box_h, w, t("network")) iy = y + 1 bar_w = w - 4 - rx_str = f"RX: {fmt_bytes_short(d.net_rx_rate)}/s" - tx_str = f"TX: {fmt_bytes_short(d.net_tx_rate)}/s" - safe_addstr(self.scr, iy, x + 2, rx_str, curses.color_pair(C_LOW)) - safe_addstr(self.scr, iy, x + 2 + len(rx_str) + 2, tx_str, curses.color_pair(C_MED)) + # Global rates + rx_str = f"\u25bc RX: {fmt_rate(d.net_rx_rate)}" + tx_str = f"\u25b2 TX: {fmt_rate(d.net_tx_rate)}" + safe_addstr(self.scr, iy, x + 2, rx_str, curses.color_pair(C_LOW) | curses.A_BOLD) + safe_addstr(self.scr, iy, x + 2 + len(rx_str) + 2, tx_str, curses.color_pair(C_MED) | curses.A_BOLD) iy += 1 - # Sparklines for RX/TX - half_w = (bar_w - 1) // 2 + # Dual sparkline (RX/TX) max_net = max(max(d.net_rx_history, default=1), max(d.net_tx_history, default=1), 1) - safe_addstr(self.scr, iy, x + 2, "RX", curses.color_pair(C_DIM)) - draw_sparkline(self.scr, iy, x + 5, half_w - 3, d.net_rx_history, max_net) - safe_addstr(self.scr, iy, x + 2 + half_w + 1, "TX", curses.color_pair(C_DIM)) - draw_sparkline(self.scr, iy, x + 2 + half_w + 4, half_w - 3, d.net_tx_history, max_net) + safe_addstr(self.scr, iy, x + 2, "RX", curses.color_pair(C_LOW)) + draw_braille_sparkline(self.scr, iy, x + 5, (bar_w - 6) // 2, d.net_rx_history, max_net, C_LOW) + half = (bar_w - 6) // 2 + safe_addstr(self.scr, iy, x + 6 + half, "TX", curses.color_pair(C_MED)) + draw_braille_sparkline(self.scr, iy, x + 9 + half, (bar_w - 6) // 2, d.net_tx_history, max_net, C_MED) iy += 1 - # Net total + # Total + errors + connections total = d.prev_net - safe_addstr(self.scr, iy, x + 2, - f"{t('total')} RX: {fmt_bytes_short(total.bytes_recv)} TX: {fmt_bytes_short(total.bytes_sent)}", - curses.color_pair(C_DIM)) + info_str = f"{t('total')} \u25bc{fmt_bytes_compact(total.bytes_recv)} \u25b2{fmt_bytes_compact(total.bytes_sent)}" + if d.net_errors_in + d.net_errors_out > 0: + info_str += f" {t('errors')}:{d.net_errors_in + d.net_errors_out}" + if d.net_drops_in + d.net_drops_out > 0: + info_str += f" {t('drops')}:{d.net_drops_in + d.net_drops_out}" + info_str += f" Conn:{d.net_connections}" + safe_addstr(self.scr, iy, x + 2, info_str[:bar_w], curses.color_pair(C_DIM)) iy += 1 - # Active interfaces - ifaces = [] - for name, stats in d.net_stats.items(): - if stats.isup and name != "lo": - addrs = d.net_addrs.get(name, []) - ip = "" - for a in addrs: - if a.family.name == "AF_INET": - ip = a.address - break - if ip: - ifaces.append(f"{name}:{ip}") - if ifaces: - iface_str = " ".join(ifaces) - safe_addstr(self.scr, iy, x + 2, iface_str[:w - 4], curses.color_pair(C_DIM)) + # Separator + safe_addstr(self.scr, iy, x + 2, "\u2500" * (bar_w), curses.color_pair(C_BORDER) | curses.A_DIM) + iy += 1 + + # Per-interface rates + for iface_name, stats in active_ifaces[:4]: + addrs = d.net_addrs.get(iface_name, []) + ip = "" + for a in addrs: + if a.family.name == "AF_INET": + ip = a.address + break + rates = d.per_iface_rates.get(iface_name, (0, 0)) + speed = f"{stats.speed}Mb" if stats.speed else "" + line = f"{iface_name:<8s}" + if ip: + line += f" {ip:<15s}" + line += f" \u25bc{fmt_rate(rates[0]):>9s} \u25b2{fmt_rate(rates[1]):>9s}" + if speed: + line += f" [{speed}]" + safe_addstr(self.scr, iy, x + 2, line[:bar_w], curses.color_pair(C_DIM)) + iy += 1 return y + box_h def _draw_disk(self, y, x, w): d = self.data num_parts = min(len(d.partitions), 4) - box_h = 3 + num_parts * 2 + box_h = 5 + num_parts * 2 draw_box(self.scr, y, x, box_h, w, t("disk")) iy = y + 1 bar_w = w - 4 - # IO rates - io_str = f"{t('read')}: {fmt_bytes_short(d.disk_read_rate)}/s {t('write')}: {fmt_bytes_short(d.disk_write_rate)}/s" - safe_addstr(self.scr, iy, x + 2, io_str, curses.color_pair(C_ACCENT)) + # IO rates + IOPS + io_str = f"\u25bc {t('read')}: {fmt_rate(d.disk_read_rate)} ({d.disk_read_iops:.0f} {t('iops')})" + io_str += f" \u25b2 {t('write')}: {fmt_rate(d.disk_write_rate)} ({d.disk_write_iops:.0f} {t('iops')})" + safe_addstr(self.scr, iy, x + 2, io_str[:bar_w], curses.color_pair(C_ACCENT)) + iy += 1 + + # Disk IO sparkline + max_disk = max(max(d.disk_read_history, default=1), max(d.disk_write_history, default=1), 1) + safe_addstr(self.scr, iy, x + 2, "R", curses.color_pair(C_LOW)) + draw_braille_sparkline(self.scr, iy, x + 4, (bar_w - 5) // 2, d.disk_read_history, max_disk, C_LOW) + half = (bar_w - 5) // 2 + safe_addstr(self.scr, iy, x + 5 + half, "W", curses.color_pair(C_HIGH)) + draw_braille_sparkline(self.scr, iy, x + 7 + half, (bar_w - 5) // 2, d.disk_write_history, max_disk, C_HIGH) iy += 1 for part, usage in d.partitions[:4]: mp = part.mountpoint if len(mp) > 15: mp = "..." + mp[-12:] - label = f"{mp} {fmt_bytes_short(usage.used)}/{fmt_bytes_short(usage.total)}" + label = f"{mp} {fmt_bytes_short(usage.used)}/{fmt_bytes_short(usage.total)} ({part.fstype})" safe_addstr(self.scr, iy, x + 2, label[:bar_w], curses.color_pair(C_DIM)) iy += 1 - draw_bar(self.scr, iy, x + 2, bar_w, usage.percent) + draw_gradient_bar(self.scr, iy, x + 2, bar_w, usage.percent) iy += 1 return y + box_h def _draw_gpu(self, y, x, w): d = self.data - box_h = 2 + len(d.gpus) * 4 + # Calculate dynamic height per GPU + total_h = 2 + for gpu in d.gpus: + gpu_procs = gpu.get("processes", []) + proc_lines = min(len(gpu_procs), 5) + # name + GPU bar + MEM bar + VRAM breakdown + stats + enc/dec + separator + procs + total_h += 6 + (1 if gpu.get("enc_util") or gpu.get("dec_util") else 0) + proc_lines + (1 if gpu_procs else 0) + + box_h = total_h draw_box(self.scr, y, x, box_h, w, t("gpu")) iy = y + 1 bar_w = w - 4 - for gpu in d.gpus: - # Name line + for gi, gpu in enumerate(d.gpus): + gpu_procs = gpu.get("processes", []) + + # Name line with PCIe info name_str = f"{gpu['vendor']} {gpu['name']}" + if gpu.get("pcie_gen") and gpu.get("pcie_width"): + name_str += f" [PCIe {gpu['pcie_gen']}x{gpu['pcie_width']}]" safe_addstr(self.scr, iy, x + 2, name_str[:bar_w], curses.color_pair(C_ACCENT) | curses.A_BOLD) iy += 1 - # GPU utilization bar + # GPU utilization bar + sparkline safe_addstr(self.scr, iy, x + 2, "GPU", curses.color_pair(C_DIM)) - draw_bar(self.scr, iy, x + 6, bar_w - 4, gpu["gpu_util"]) + gpu_bar_w = bar_w - 4 + spark_w = min(20, gpu_bar_w // 3) + draw_gradient_bar(self.scr, iy, x + 6, gpu_bar_w - spark_w - 1, gpu["gpu_util"]) + if gi < len(d.gpu_util_history): + draw_braille_sparkline(self.scr, iy, x + 6 + gpu_bar_w - spark_w, spark_w, + d.gpu_util_history[gi]) iy += 1 - # Memory bar - mem_label = f"MEM {fmt_bytes_short(gpu['mem_used'])}/{fmt_bytes_short(gpu['mem_total'])}" + # Memory bar + sparkline safe_addstr(self.scr, iy, x + 2, "MEM", curses.color_pair(C_DIM)) - draw_bar(self.scr, iy, x + 6, bar_w - 4, gpu["mem_util"]) + draw_gradient_bar(self.scr, iy, x + 6, gpu_bar_w - spark_w - 1, gpu["mem_util"]) + if gi < len(d.gpu_mem_history): + draw_braille_sparkline(self.scr, iy, x + 6 + gpu_bar_w - spark_w, spark_w, + d.gpu_mem_history[gi]) iy += 1 - # Stats line - stats = f"Temp:{gpu['temp']}C Pwr:{gpu['power']:.0f}W Fan:{gpu['fan']}%" + # VRAM breakdown: processes vs driver/reserved + mem_total = gpu["mem_total"] + vram_procs = gpu.get("vram_procs", 0) + vram_other = gpu.get("vram_other", 0) + mem_free = max(0, mem_total - gpu["mem_used"]) if mem_total else 0 + + vram_str = f"VRAM {fmt_bytes_compact(gpu['mem_used'])}/{fmt_bytes_compact(mem_total)}" + vram_str += f" Apps:{fmt_bytes_compact(vram_procs)}" + vram_str += f" Sys:{fmt_bytes_compact(vram_other)}" + vram_str += f" Free:{fmt_bytes_compact(mem_free)}" + safe_addstr(self.scr, iy, x + 2, vram_str[:bar_w], curses.color_pair(C_DIM)) + iy += 1 + + # VRAM stacked bar: apps (green) | sys/driver (yellow) | free (dim) + if mem_total > 0: + seg_apps = vram_procs / mem_total * 100 + seg_sys = vram_other / mem_total * 100 + segments = [] + if seg_apps > 0.5: + segments.append((seg_apps, C_LOW, "\u2588")) + if seg_sys > 0.5: + segments.append((seg_sys, C_MED, "\u2588")) + draw_stacked_bar(self.scr, iy, x + 2, bar_w, segments) + iy += 1 + + # Stats line: temp, power, fan, clocks + stats = f"Temp:{gpu['temp']}\u00b0C" + if gpu.get("power_limit"): + stats += f" Pwr:{gpu['power']:.0f}/{gpu['power_limit']:.0f}W" + else: + stats += f" Pwr:{gpu['power']:.0f}W" + stats += f" Fan:{gpu['fan']}%" if gpu["clk_gpu"]: - stats += f" GPU:{gpu['clk_gpu']}MHz" + clk_str = f" Core:{gpu['clk_gpu']}MHz" + if gpu.get("clk_gpu_max"): + clk_str += f"/{gpu['clk_gpu_max']}" + stats += clk_str if gpu["clk_mem"]: - stats += f" MEM:{gpu['clk_mem']}MHz" + stats += f" Mem:{gpu['clk_mem']}MHz" safe_addstr(self.scr, iy, x + 2, stats[:bar_w], curses.color_pair(C_DIM)) iy += 1 + # Encoder/decoder + if gpu.get("enc_util") or gpu.get("dec_util"): + enc_str = f"Enc:{gpu['enc_util']}% Dec:{gpu['dec_util']}%" + safe_addstr(self.scr, iy, x + 2, enc_str[:bar_w], curses.color_pair(C_DIM)) + iy += 1 + + # GPU processes + if gpu_procs: + safe_addstr(self.scr, iy, x + 2, + f"{'PID':>7s} {'TYPE':4s} {'VRAM':>8s} NAME", + curses.color_pair(C_CYAN_DIM) | curses.A_BOLD) + iy += 1 + for proc in gpu_procs[:5]: + ptype = proc.get("type", "?") + pmem = proc.get("mem", 0) + pname = proc.get("name", "?") + pid = proc.get("pid", 0) + # VRAM mini-bar + if mem_total > 0: + pct = pmem / mem_total * 100 + mini_w = 4 + filled = max(0, min(mini_w, int(mini_w * pct / 100))) + mini = "\u2588" * filled + "\u2591" * (mini_w - filled) + else: + mini = "\u2591" * 4 + pct = 0 + line = f"{pid:>7d} {ptype:4s} {fmt_bytes_compact(pmem):>6s} {mini} {pname}" + col = C_LOW if pct < 30 else (C_MED if pct < 60 else C_HIGH) + safe_addstr(self.scr, iy, x + 2, line[:bar_w], curses.color_pair(col)) + iy += 1 + return y + box_h def _draw_temps(self, y, x, w): @@ -965,7 +1579,7 @@ class Renderer: draw_box(self.scr, y, x, box_h, w, t("temps")) iy = y + 1 - cols = max(1, w // 30) + cols = max(1, w // 28) col_w = (w - 2) // cols for idx, (label, current, high, critical) in enumerate(entries[:((box_h - 2) * cols)]): @@ -982,8 +1596,141 @@ class Renderer: col_attr = C_HIGH if critical and current > critical: col_attr = C_CRIT - text = f"{label[:12]:12s} {current:5.1f}C" + + # Temperature with mini-bar + temp_pct = min(current / (critical or 110) * 100, 100) + mini_bar_w = max(col_w - 22, 3) + text = f"{label[:10]:10s} {current:5.1f}\u00b0C " safe_addstr(self.scr, cy, cx, text, curses.color_pair(col_attr)) + if mini_bar_w > 2: + filled = int(mini_bar_w * temp_pct / 100) + try: + self.scr.addstr(cy, cx + len(text), "\u2588" * filled, + curses.color_pair(col_attr)) + self.scr.addstr(cy, cx + len(text) + filled, + "\u2591" * (mini_bar_w - filled), + curses.color_pair(C_DIM) | curses.A_DIM) + except curses.error: + pass + + return y + box_h + + def _draw_system_info(self, y, x, w): + d = self.data + box_h = 4 + draw_box(self.scr, y, x, box_h, w, t("system")) + iy = y + 1 + bar_w = w - 4 + + # Line 1: context switches + interrupts + line1 = f"{t('ctx_sw')}:{fmt_count(int(d.ctx_switch_rate))}/s" + line1 += f" {t('interrupts')}:{fmt_count(int(d.interrupt_rate))}/s" + line1 += f" {t('threads')}:{d.total_threads}" + safe_addstr(self.scr, iy, x + 2, line1[:bar_w], curses.color_pair(C_DIM)) + iy += 1 + + # Line 2: file descriptors, open files + try: + open_fds = len(os.listdir("/proc/self/fd")) + except Exception: + open_fds = 0 + try: + with open("/proc/sys/fs/file-nr") as f: + parts = f.read().strip().split() + total_fds = int(parts[0]) + max_fds = int(parts[2]) + except Exception: + total_fds = 0 + max_fds = 0 + + line2 = f"{t('fds')}:{total_fds}/{max_fds}" + try: + with open("/proc/stat") as f: + for ln in f: + if ln.startswith("processes"): + line2 += f" Forks:{fmt_count(int(ln.split()[1]))}" + break + except Exception: + pass + safe_addstr(self.scr, iy, x + 2, line2[:bar_w], curses.color_pair(C_DIM)) + + return y + box_h + + def _draw_battery(self, y, x, w): + d = self.data + if not d.battery: + return y + + bat = d.battery + box_h = 3 + draw_box(self.scr, y, x, box_h, w, t("battery")) + iy = y + 1 + bar_w = w - 4 + + status = "\u26a1" if bat.power_plugged else "\u2500" + time_left = "" + if bat.secsleft > 0 and bat.secsleft != psutil.POWER_TIME_UNLIMITED: + h_left = bat.secsleft // 3600 + m_left = (bat.secsleft % 3600) // 60 + time_left = f" ({h_left}h{m_left:02d}m)" + + label = f"{status} {bat.percent:.0f}%{time_left}" + safe_addstr(self.scr, iy, x + 2, label, curses.color_pair(C_ACCENT)) + bar_x = x + 2 + len(label) + 1 + remaining_w = bar_w - len(label) - 1 + if remaining_w > 5: + draw_gradient_bar(self.scr, iy, bar_x, remaining_w, bat.percent, show_pct=False) + + return y + box_h + + def _draw_users(self, y, x, w): + d = self.data + if not d.user_stats: + return y + + # Logged in users (unique) + logged = {} + for u in d.logged_users: + if u.name not in logged: + logged[u.name] = {"terminal": u.terminal or "", "host": u.host or ""} + + num_users = min(len(d.user_stats), 8) + box_h = 2 + 1 + num_users # border + header + rows + title = f"{t('users')} ({len(logged)} {t('logged_in')})" + draw_box(self.scr, y, x, box_h, w, title) + iy = y + 1 + bar_w = w - 4 + + # Header + hdr = f"{'USER':<12s} {'CPU%':>6s} {'MEM%':>6s} {'PROCS':>5s} {'THR':>5s} {'STATUS'}" + safe_addstr(self.scr, iy, x + 2, hdr[:bar_w], curses.color_pair(C_CYAN_DIM) | curses.A_BOLD) + iy += 1 + + for user, stats in d.user_stats[:num_users]: + cpu = stats["cpu"] + mem = stats["mem"] + procs = stats["procs"] + threads = stats["threads"] + + # Online indicator + is_logged = user in logged + status = "\u25cf online" if is_logged else "\u25cb" + if is_logged and logged[user]["host"]: + status += f" ({logged[user]['host']})" + + line = f"{user:<12s} {cpu:>6.1f} {mem:>6.1f} {procs:>5d} {threads:>5d} {status}" + + # Color by CPU load + col = C_DIM + if cpu > 50: + col = C_HIGH + elif cpu > 10: + col = C_MED + elif cpu > 1: + col = C_LOW + + safe_addstr(self.scr, iy, x + 2, line[:bar_w], curses.color_pair(col)) + iy += 1 return y + box_h @@ -996,19 +1743,19 @@ class Renderer: box_h = avail_h processes = pm.filtered_processes(d.processes) - # Title with keybindings + # Title filter_info = f" {t('filter')}: '{pm.filter_text}'" if pm.filter_text else "" - title = f"{t('processes')} [{d.process_sort}] F9:kill F7/F8:{t('hint_nice')} F5:{t('hint_details')} /:{t('hint_filter')}{filter_info}" + tree_info = " [TREE]" if pm.tree_mode else "" + title = f"{t('processes')} [{d.process_sort}]{tree_info} t:tree F9:kill /:{t('hint_filter')}{filter_info}" draw_box(self.scr, y, x, box_h, w, title) iy = y + 1 - # Header - hdr = f"{'PID':>7s} {'USER':<10s} {'CPU%':>6s} {'MEM%':>6s} {'THR':>4s} {'RES':>8s} {'S':1s} {t('name')}" + # Header with IO column + hdr = f"{'PID':>7s} {'USER':<9s} {'CPU%':>6s} {'MEM%':>6s} {'THR':>4s} {'RES':>7s} {'IO R/W':>12s} {'S':1s} {t('name')}" safe_addstr(self.scr, iy, x + 1, hdr[:w - 2], curses.color_pair(C_HEADER)) iy += 1 - max_procs = box_h - 4 # leave room for status bar - # Adjust scroll so selected is visible + max_procs = box_h - 4 if pm.selected_idx < self.scroll_offset: self.scroll_offset = pm.selected_idx elif pm.selected_idx >= self.scroll_offset + max_procs: @@ -1023,13 +1770,26 @@ class Renderer: name = proc.get("name", "?") or "?" cpu = proc.get("cpu_percent", 0) or 0 mem = proc.get("memory_percent", 0) or 0 - user = (proc.get("username") or "?")[:10] + user = (proc.get("username") or "?")[:9] threads = proc.get("num_threads", 0) or 0 mem_info = proc.get("memory_info") rss = mem_info.rss if mem_info else 0 status = (proc.get("status") or "?")[0].upper() - line = f"{pid:>7d} {user:<10s} {cpu:>6.1f} {mem:>6.1f} {threads:>4d} {fmt_bytes(rss)} {status} {name}" + # IO info + io_info = proc.get("io_counters") + if io_info: + io_str = f"{fmt_bytes_compact(io_info.read_bytes):>5s}/{fmt_bytes_compact(io_info.write_bytes):<5s}" + else: + io_str = " -/ -" + + # Tree indentation + tree_depth = proc.get("_tree_depth", 0) + if tree_depth > 0: + prefix = " " * min(tree_depth - 1, 4) + "\u2514\u2500" + name = prefix + name + + line = f"{pid:>7d} {user:<9s} {cpu:>6.1f} {mem:>6.1f} {threads:>4d} {fmt_bytes_compact(rss):>7s} {io_str} {status} {name}" line = line[:w - 2].ljust(w - 2) if abs_idx == pm.selected_idx: @@ -1043,24 +1803,24 @@ class Renderer: safe_addstr(self.scr, iy, x + 1, line, curses.color_pair(col)) iy += 1 - # Status bar at bottom of process box + # Status bar status_y = y + box_h - 2 status = pm.get_status() if status: safe_addstr(self.scr, status_y, x + 2, status[:w - 4], curses.color_pair(C_CRIT) | curses.A_BOLD) else: - hint = f"q:{t('hint_quit')} c/m/p/n:{t('hint_sort')} r:{t('hint_reverse')} /:{t('hint_filter')} K:KILL T:TERM F9:{t('hint_signals')} d:{t('hint_details')} +/-:{t('hint_nice')}" + hint = f"q:{t('hint_quit')} c/m/p/n/i:{t('hint_sort')} r:{t('hint_reverse')} /:{t('hint_filter')} t:{t('hint_tree')} K:KILL T:TERM F9:{t('hint_signals')} d:{t('hint_details')} +/-:{t('hint_nice')}" safe_addstr(self.scr, status_y, x + 2, hint[:w - 4], curses.color_pair(C_DIM)) - # Scrollbar indicator + # Scrollbar if len(processes) > max_procs: total = len(processes) pct_str = f" [{self.scroll_offset + 1}-{min(self.scroll_offset + max_procs, total)}/{total}] " safe_addstr(self.scr, y + box_h - 1, x + w - len(pct_str) - 2, pct_str, curses.color_pair(C_BORDER)) - # Draw popups on top + # Popups if pm.show_kill_menu: self._draw_kill_menu(y, x, w, avail_h) if pm.show_details: @@ -1081,11 +1841,9 @@ class Renderer: mx = (w - menu_w) // 2 my = y + 2 - # Background for row in range(menu_h): safe_addstr(self.scr, my + row, mx, " " * menu_w, curses.color_pair(C_POPUP_BG)) - # Title title = f" {t('send_signal_to')} {proc_name}({proc_pid}) " safe_addstr(self.scr, my, mx, title[:menu_w].center(menu_w), curses.color_pair(C_POPUP_HL) | curses.A_BOLD) @@ -1094,7 +1852,7 @@ class Renderer: for i, (name, _sig) in enumerate(ProcessManager.SIGNALS): row_y = my + 2 + i if i == pm.kill_menu_idx: - safe_addstr(self.scr, row_y, mx + 1, f" > {name} ".ljust(menu_w - 2), + safe_addstr(self.scr, row_y, mx + 1, f" \u25b6 {name} ".ljust(menu_w - 2), curses.color_pair(C_POPUP_HL) | curses.A_BOLD) else: safe_addstr(self.scr, row_y, mx + 1, f" {name} ".ljust(menu_w - 2), @@ -1111,12 +1869,11 @@ class Renderer: pm.show_details = False return - popup_w = min(w - 4, 70) - popup_h = 18 + popup_w = min(w - 4, 72) + popup_h = 20 mx = (w - popup_w) // 2 my = y + 1 - # Background for row in range(popup_h): safe_addstr(self.scr, my + row, mx, " " * popup_w, curses.color_pair(C_POPUP_BG)) @@ -1128,10 +1885,11 @@ class Renderer: lines = [ f" PID: {details['pid']} PPID: {details['ppid']}", f" User: {details['username']} Status: {details['status']}", - f" Nice: {details['nice']} Threads: {details['num_threads']}", + f" Nice: {details['nice']} Threads: {details['num_threads']} CPUs: {details.get('cpu_affinity', '?')}", f" CPU: {details['cpu_percent']:.1f}% MEM: {details['memory_percent']:.1f}%", - f" RSS: {details['rss']} VMS: {details['vms']}", - f" IO R: {details['io_read']} IO W: {details['io_write']}", + f" RSS: {details['rss']} VMS: {details['vms']} Shared: {details.get('shared', '?')}", + f" IO Read: {details['io_read']} ({details.get('io_read_ops', '?')} ops)", + f" IO Write: {details['io_write']} ({details.get('io_write_ops', '?')} ops)", f" FDs: {details['num_fds']} {t('connections')}: {details['connections']}", f" {t('created')}: {details['create_time']}", f" Exe: {details.get('exe', '?')}", @@ -1178,11 +1936,9 @@ class Renderer: mx = (w - popup_w) // 2 my = (h - popup_h) // 2 - # Background for row in range(popup_h): safe_addstr(self.scr, my + row, mx, " " * popup_w, curses.color_pair(C_POPUP_BG)) - # Title title = f" {t('help_title')} " safe_addstr(self.scr, my, mx, title.center(popup_w), curses.color_pair(C_POPUP_HL) | curses.A_BOLD) @@ -1195,7 +1951,6 @@ class Renderer: if not key and not desc: continue if not desc: - # Section header safe_addstr(self.scr, row_y, mx + 2, key, curses.color_pair(C_POPUP_HL) | curses.A_BOLD) else: @@ -1216,21 +1971,21 @@ class Renderer: # ─── Intro ────────────────────────────────────────────────────────── -VERSION = "1.0.0" +VERSION = "2.1.0" LOGO_ART = [ - " ███████╗███╗ ██╗████████╗██████╗ ██████╗ ██████╗ ██╗ ██╗", - " ██╔════╝████╗ ██║╚══██╔══╝██╔══██╗██╔═══██╗██╔══██╗╚██╗ ██╔╝", - " █████╗ ██╔██╗ ██║ ██║ ██████╔╝██║ ██║██████╔╝ ╚████╔╝ ", - " ██╔══╝ ██║╚██╗██║ ██║ ██╔══██╗██║ ██║██╔═══╝ ╚██╔╝ ", - " ███████╗██║ ╚████║ ██║ ██║ ██║╚██████╔╝██║ ██║ ", - " ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ", - " ███╗ ███╗ ██████╗ ███╗ ██╗ ", - " ████╗ ████║██╔═══██╗████╗ ██║ ", - " ██╔████╔██║██║ ██║██╔██╗ ██║ ", - " ██║╚██╔╝██║██║ ██║██║╚██╗██║ ", - " ██║ ╚═╝ ██║╚██████╔╝██║ ╚████║ ", - " ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ", + " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557", + " \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u255a\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255d\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255a\u2588\u2588\u2557 \u2588\u2588\u2554\u255d", + " \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u255a\u2588\u2588\u2588\u2588\u2554\u255d ", + " \u2588\u2588\u2554\u2550\u2550\u255d \u2588\u2588\u2551\u255a\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u255d \u255a\u2588\u2588\u2554\u255d ", + " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d\u2588\u2588\u2551 \u2588\u2588\u2551 ", + " \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u2550\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d ", + " \u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557 ", + " \u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 ", + " \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551 ", + " \u2588\u2588\u2551\u255a\u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255a\u2588\u2588\u2557\u2588\u2588\u2551 ", + " \u2588\u2588\u2551 \u255a\u2550\u255d \u2588\u2588\u2551\u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d\u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2551 ", + " \u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u2550\u2550\u255d ", ] BOOT_LINES = [ @@ -1244,8 +1999,6 @@ BOOT_LINES = [ ("> Electric Entropy Lab // ENTROPYMON", 1.8), ] -import random as _random - def intro_splash(stdscr): """Animated boot splash inspired by Spectre/Pulse.""" @@ -1258,7 +2011,6 @@ def intro_splash(stdscr): if h < 15 or w < 60: return - # Gather system info cores = psutil.cpu_count() cores_phys = psutil.cpu_count(logical=False) mem = psutil.virtual_memory() @@ -1278,7 +2030,7 @@ def intro_splash(stdscr): glitch_chars = "@#$%&*!?<>/\\|{}[]~^=+:;0123456789" - # ── Phase 1: Matrix rain background ── + # Phase 1: Matrix rain rain_cols = [_random.randint(-h, 0) for _ in range(w)] for frame in range(25): stdscr.erase() @@ -1291,8 +2043,6 @@ def intro_splash(stdscr): if 0 <= ry < h: ch = _random.choice(glitch_chars) brightness = C_LOW if row_off == 0 else C_DIM - if row_off == 0: - brightness = C_LOW try: stdscr.addch(ry, col, ch, curses.color_pair(brightness) | (curses.A_BOLD if row_off == 0 else curses.A_DIM)) @@ -1302,7 +2052,7 @@ def intro_splash(stdscr): stdscr.getch() time.sleep(0.04) - # ── Phase 2: Boot sequence with typing effect ── + # Phase 2: Boot sequence boot_lines = [ (f"> ENTROPYMON v{VERSION} // Electric Entropy Lab", C_ACCENT), (f"> host: {hostname}", C_DIM), @@ -1321,12 +2071,8 @@ def intro_splash(stdscr): for line_text, line_col in boot_lines: typed_lines.append((line_text, line_col)) - - # Type this line character by character for char_idx in range(len(line_text) + 1): stdscr.erase() - - # Fading rain in background for col in range(0, w, 3): ry = _random.randint(0, h - 1) ch = _random.choice(glitch_chars) @@ -1334,18 +2080,11 @@ def intro_splash(stdscr): stdscr.addch(ry, col, ch, curses.color_pair(C_DIM) | curses.A_DIM) except curses.error: pass - - # Draw all completed lines for i, (lt, lc) in enumerate(typed_lines[:-1]): - safe_addstr(stdscr, start_y + i, 2, lt, - curses.color_pair(lc)) - - # Draw current line being typed + safe_addstr(stdscr, start_y + i, 2, lt, curses.color_pair(lc)) current_y = start_y + len(typed_lines) - 1 partial = line_text[:char_idx] safe_addstr(stdscr, current_y, 2, partial, curses.color_pair(line_col)) - - # Blinking cursor if char_idx < len(line_text): cursor_x = 2 + char_idx if cursor_x < w: @@ -1354,27 +2093,22 @@ def intro_splash(stdscr): curses.color_pair(C_TITLE) | curses.A_BOLD) except curses.error: pass - stdscr.refresh() stdscr.getch() - - # Variable typing speed if line_text[char_idx - 1:char_idx] in ".:": time.sleep(0.08) elif _random.random() < 0.15: time.sleep(0.06) else: time.sleep(0.015) - time.sleep(0.15) time.sleep(0.4) - # ── Phase 3: Glitch transition ── + # Phase 3: Glitch for frame in range(6): stdscr.erase() for row in range(h): - offset = _random.randint(-3, 3) for col in range(w): try: stdscr.addch(row, col, _random.choice(glitch_chars), @@ -1384,41 +2118,26 @@ def intro_splash(stdscr): stdscr.refresh() time.sleep(0.04) - # ── Phase 4: Logo decrypt ── + # Phase 4: Logo decrypt max_logo_w = max(len(l) for l in LOGO_ART) logo_y = (h - len(LOGO_ART)) // 2 - 1 logo_x = (w - max_logo_w) // 2 - # Build target grid and scrambled state target = [] - scrambled = [] for line in LOGO_ART: t_row = list(line.ljust(max_logo_w)) - s_row = [] - for ch in t_row: - if ch == ' ': - s_row.append(' ') - else: - s_row.append(_random.choice(glitch_chars)) target.append(t_row) - scrambled.append(s_row) - # Reveal each character with multiple scramble frames before settling total_chars = max_logo_w reveal_order = list(range(total_chars)) _random.shuffle(reveal_order) - - # Group into waves wave_size = max(1, total_chars // 12) waves = [reveal_order[i:i + wave_size] for i in range(0, len(reveal_order), wave_size)] - revealed_cols = set() for wave_idx, wave in enumerate(waves): - # Several scramble frames per wave for scramble_frame in range(4): stdscr.erase() - for row_idx in range(len(LOGO_ART)): for col_idx in range(max_logo_w): if logo_x + col_idx >= w - 1: @@ -1446,8 +2165,6 @@ def intro_splash(stdscr): curses.color_pair(C_LOW if col_idx in wave else C_DIM) | curses.A_DIM) except curses.error: pass - - # Show version once past halfway if wave_idx > len(waves) // 2: ver_str = f"v{VERSION}" brand_str = "Electric Entropy Lab" @@ -1455,14 +2172,12 @@ def intro_splash(stdscr): ver_str, curses.color_pair(C_ACCENT) | curses.A_BOLD) safe_addstr(stdscr, logo_y + len(LOGO_ART) + 2, (w - len(brand_str)) // 2, brand_str, curses.color_pair(C_DIM)) - stdscr.refresh() stdscr.getch() time.sleep(0.035) - revealed_cols.update(wave) - # Final clean frame + # Final frame stdscr.erase() for row_idx, line in enumerate(LOGO_ART): safe_addstr(stdscr, logo_y + row_idx, logo_x, line, @@ -1477,7 +2192,7 @@ def intro_splash(stdscr): safe_addstr(stdscr, logo_y + len(LOGO_ART) + 3, (w - len(lang_str)) // 2, lang_str, curses.color_pair(C_DIM)) - # Scanning line effect + # Scan line for scan_y in range(h): try: for sx in range(w): @@ -1486,8 +2201,6 @@ def intro_splash(stdscr): pass stdscr.refresh() time.sleep(0.008) - - # Restore the line if logo_y <= scan_y < logo_y + len(LOGO_ART): row_idx = scan_y - logo_y safe_addstr(stdscr, scan_y, logo_x, LOGO_ART[row_idx], @@ -1520,44 +2233,86 @@ def intro_splash(stdscr): show_intro = True +def _mini_splash(stdscr): + """Quick 1-second mini splash when full intro is disabled.""" + curses.curs_set(0) + init_colors() + h, w = stdscr.getmaxyx() + stdscr.erase() + + title = f"ENTROPYMON v{VERSION}" + brand = "Electric Entropy Lab" + mid_y = h // 2 + + # Flash in + safe_addstr(stdscr, mid_y - 1, (w - len(title)) // 2, title, + curses.color_pair(C_TITLE) | curses.A_BOLD) + safe_addstr(stdscr, mid_y + 1, (w - len(brand)) // 2, brand, + curses.color_pair(C_DIM)) + + # Horizontal line sweep + for cx in range(w): + try: + stdscr.addch(mid_y, cx, "\u2500", curses.color_pair(C_ACCENT) | curses.A_BOLD) + except curses.error: + pass + if cx % 4 == 0: + stdscr.refresh() + time.sleep(0.002) + stdscr.refresh() + time.sleep(0.5) + + def _curses_main(stdscr): if show_intro: intro_splash(stdscr) + else: + _mini_splash(stdscr) curses.curs_set(0) stdscr.nodelay(True) - stdscr.timeout(int(REFRESH_INTERVAL * 1000)) init_colors() data = SystemData() renderer = Renderer(stdscr, data) - # First collection to initialize rates data.update() time.sleep(0.1) + last_update = 0 + while True: - data.update() + # Only update data at REFRESH_INTERVAL, not on every keypress + now = time.monotonic() + if now - last_update >= REFRESH_INTERVAL: + data.update() + last_update = now + renderer.draw() + # Wait for key with short timeout for responsive UI + stdscr.timeout(50) # 50ms = 20fps redraw key = stdscr.getch() + if key == -1: + continue + pm = renderer.proc_mgr processes = pm.filtered_processes(data.processes) max_idx = max(0, len(processes) - 1) - # ── Help overlay ── + # Help overlay if renderer.show_help: if key in (ord("h"), ord("?"), curses.KEY_F1, 27): renderer.show_help = False continue - # ── Filter mode input ── + # Filter mode if pm.filter_mode: - if key == 27: # ESC + if key == 27: pm.filter_mode = False pm.filter_text = "" pm.selected_idx = 0 - elif key == 10 or key == 13: # Enter + elif key == 10 or key == 13: pm.filter_mode = False pm.selected_idx = 0 elif key in (curses.KEY_BACKSPACE, 127, 8): @@ -1568,7 +2323,7 @@ def _curses_main(stdscr): pm.selected_idx = 0 continue - # ── Nice dialog input ── + # Nice dialog if pm.show_nice_dialog: if key == 27: pm.show_nice_dialog = False @@ -1588,7 +2343,7 @@ def _curses_main(stdscr): pm.nice_value += chr(key) continue - # ── Kill menu input ── + # Kill menu if pm.show_kill_menu: if key == 27: pm.show_kill_menu = False @@ -1602,16 +2357,16 @@ def _curses_main(stdscr): pm.show_kill_menu = False continue - # ── Details popup ── + # Details popup if pm.show_details: if key == 27 or key == ord("d") or key == ord("q"): pm.show_details = False continue - # ── Normal mode ── + # Normal mode if key == ord("q") or key == ord("Q"): break - elif key == 27: # ESC + elif key == 27: break elif key in (ord("h"), ord("?"), curses.KEY_F1): renderer.show_help = True @@ -1624,33 +2379,51 @@ def _curses_main(stdscr): data.process_sort = "cpu" data.process_sort_reverse = True pm.selected_idx = 0 + pm.selected_pid = None elif key == ord("m"): data.process_sort = "mem" data.process_sort_reverse = True pm.selected_idx = 0 + pm.selected_pid = None elif key == ord("p"): data.process_sort = "pid" data.process_sort_reverse = False pm.selected_idx = 0 + pm.selected_pid = None elif key == ord("n"): data.process_sort = "name" data.process_sort_reverse = False pm.selected_idx = 0 + pm.selected_pid = None + elif key == ord("i"): + data.process_sort = "io" + data.process_sort_reverse = True + pm.selected_idx = 0 + pm.selected_pid = None + elif key == ord("t"): + pm.tree_mode = not pm.tree_mode + pm.selected_idx = 0 + pm.selected_pid = None elif key == ord("r"): data.process_sort_reverse = not data.process_sort_reverse elif key == curses.KEY_DOWN or key == ord("j"): pm.selected_idx = min(pm.selected_idx + 1, max_idx) + pm.selected_pid = processes[pm.selected_idx].get("pid") if processes else None elif key == curses.KEY_UP or key == ord("k"): pm.selected_idx = max(pm.selected_idx - 1, 0) + pm.selected_pid = processes[pm.selected_idx].get("pid") if processes else None elif key == curses.KEY_NPAGE: pm.selected_idx = min(pm.selected_idx + 20, max_idx) + pm.selected_pid = processes[pm.selected_idx].get("pid") if processes else None elif key == curses.KEY_PPAGE: pm.selected_idx = max(pm.selected_idx - 20, 0) + pm.selected_pid = processes[pm.selected_idx].get("pid") if processes else None elif key == curses.KEY_HOME: pm.selected_idx = 0 + pm.selected_pid = processes[0].get("pid") if processes else None elif key == curses.KEY_END: pm.selected_idx = max_idx - # Kill shortcuts + pm.selected_pid = processes[max_idx].get("pid") if processes else None elif key == curses.KEY_F9: pm.show_kill_menu = True pm.kill_menu_idx = 0 @@ -1658,22 +2431,18 @@ def _curses_main(stdscr): pm.kill_selected(processes, signal.SIGTERM) elif key == ord("K"): pm.kill_selected(processes, signal.SIGKILL) - # Nice elif key == ord("+") or key == curses.KEY_F8: pm.show_nice_dialog = True pm.nice_value = "" elif key == ord("-") or key == curses.KEY_F7: pm.show_nice_dialog = True pm.nice_value = "-" - # Details elif key == ord("d") or key == curses.KEY_F5: pm.show_details = True - # Filter elif key == ord("/") or key == ord("f"): pm.filter_mode = True pm.filter_text = "" pm.selected_idx = 0 - # Enter on process = details elif key == 10 or key == 13: pm.show_details = True @@ -1684,24 +2453,42 @@ def get_config_path(): return os.path.join(config_dir, "config") -def load_saved_lang(): +def load_config(): + """Load all config values as dict.""" path = get_config_path() + cfg = {} try: with open(path) as f: for line in f: - if line.startswith("lang="): - lang = line.strip().split("=", 1)[1] - if lang in LANGS: - return lang + line = line.strip() + if "=" in line: + k, v = line.split("=", 1) + cfg[k] = v except FileNotFoundError: pass + return cfg + + +def save_config(cfg): + """Save config dict to file.""" + path = get_config_path() + with open(path, "w") as f: + for k, v in cfg.items(): + f.write(f"{k}={v}\n") + + +def load_saved_lang(): + cfg = load_config() + lang = cfg.get("lang") + if lang in LANGS: + return lang return None def save_lang(lang): - path = get_config_path() - with open(path, "w") as f: - f.write(f"lang={lang}\n") + cfg = load_config() + cfg["lang"] = lang + save_config(cfg) def ask_lang(): @@ -1720,10 +2507,8 @@ def ask_lang(): except (EOFError, KeyboardInterrupt): print() sys.exit(0) - # Accept number if choice.isdigit() and 1 <= int(choice) <= len(LANGS): return LANGS[int(choice) - 1] - # Accept lang code if choice in LANGS: return choice print(f" 1-{len(LANGS)} or lang code ({'/'.join(LANGS)})") @@ -1743,6 +2528,8 @@ def main(): help="Reset saved language preference and ask again") parser.add_argument("--no-intro", action="store_true", help="Skip the boot intro animation") + parser.add_argument("--intro", action="store_true", + help="Re-enable boot intro animation") parser.add_argument("--version", action="version", version=f"entropymon {VERSION}") args = parser.parse_args() @@ -1772,13 +2559,91 @@ def main(): save_lang(lang) current_lang = lang + if args.intro: + cfg = load_config() + cfg["intro"] = "on" + save_config(cfg) + print(" Intro re-enabled.") + if args.no_intro: show_intro = False + else: + cfg = load_config() + # Check if intro is permanently disabled + if cfg.get("intro") == "off": + show_intro = False + else: + # Track run count, ask on 2nd run + runs = int(cfg.get("runs", "0")) + 1 + cfg["runs"] = str(runs) + save_config(cfg) + if runs == 2: + try: + print(" \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510") + print(" \u2502 ENTROPYMON \u2502") + print(" \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524") + print(" \u2502 Disable boot intro animation? \u2502") + print(" \u2502 (you can re-enable with --intro) \u2502") + print(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518") + ans = input(" y/n > ").strip().lower() + if ans in ("y", "yes", "t", "tak"): + cfg["intro"] = "off" + save_config(cfg) + show_intro = False + print(" Intro disabled. Use --intro to show it again.") + except (EOFError, KeyboardInterrupt): + print() + + # Ensure we have a proper TTY + if not sys.stdin.isatty(): + print("Error: entropymon requires a terminal (TTY).") + sys.exit(1) + + # Ensure TERM is set and valid for curses + term = os.environ.get("TERM", "") + if not term: + os.environ["TERM"] = "xterm-256color" + else: + # Test if current TERM has valid terminfo + try: + curses.setupterm() + except curses.error: + # Fallback for exotic terminals (ghostty, kitty SSH, etc.) + for fallback in ("xterm-256color", "xterm", "screen-256color", "vt100"): + os.environ["TERM"] = fallback + try: + curses.setupterm() + break + except curses.error: + continue + + # Reset terminal state before entering curses + os.system("stty sane 2>/dev/null") + + # Save terminal content before curses takes over + sys.stdout.write("\033[?1049h") # switch to alternate screen + sys.stdout.flush() try: curses.wrapper(_curses_main) + except curses.error as e: + sys.stdout.write("\033[?1049l") # restore main screen + sys.stdout.flush() + os.system("stty sane 2>/dev/null") + term = os.environ.get("TERM", "?") + print(f"\nTerminal error: {e}") + print(f" TERM={term}") + print(f"\nTry one of:") + print(f" TERM=xterm-256color entropymon") + print(f" reset && entropymon") + print(f" entropymon --no-intro") + sys.exit(1) except KeyboardInterrupt: pass + finally: + sys.stdout.write("\033[?1049l") # restore main screen + sys.stdout.flush() + os.system("stty sane 2>/dev/null") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index a45f059..ec4197c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,9 +38,9 @@ nvidia = ["pynvml>=11.0.0"] all = ["pynvml>=11.0.0"] [project.urls] -Homepage = "https://git.electricentropy.eu/ar3dh3l/systats" -Repository = "https://git.electricentropy.eu/ar3dh3l/systats" -Issues = "https://git.electricentropy.eu/ar3dh3l/systats/issues" +Homepage = "https://git.electricentropy.eu/ar3dh3l/entropymon" +Repository = "https://git.electricentropy.eu/ar3dh3l/entropymon" +Issues = "https://git.electricentropy.eu/ar3dh3l/entropymon/issues" [project.scripts] entropymon = "entropymon.monitor:main"