#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.13" # dependencies = ["textual>=1.0,<2.0", "httpx>=0.27", "psutil>=6"] # /// """ Personal Dashboard TUI — polls the Next.js app (localhost:4317) and renders source snapshots full-screen. Auto-starts the production server if needed. Usage: uv run tui/dashboard.py Keys: 1/2/3 = pages [ ] = cycle pages : = command bar k = kk menu r = refresh t = cycle theme q = quit Command bar (:): runs through `zsh -ic` so aliases/functions (tock, doing, note, daily-append, task…) all work. Prefix with ! to suspend the TUI and run interactively in the terminal — needed for gum prompts and full TUIs. kk overlay (k): native fuzzy-filter version of the dash menu, parsed live from the entries array in ~/.zshrc. Picks resolve in tiers, staying inside the TUI when possible: KK_INPUT entries (scratch, note, dict, am search/vol) get a native text prompt; kk_choices entries (periodic notes, dayreview, daydata, zine) get a native two-option pick; "am playlist" fetches the list and picks natively; KK_CAPTURED entries run straight through. Only real TUIs and complex gum flows (task, event, md, vault, newsboat, …) suspend and run `dash ""` in the terminal (dash accepts a key argument to skip its gum menu). """ from __future__ import annotations import json import re import shlex import subprocess import time from collections import deque from datetime import date, datetime from pathlib import Path import httpx import psutil from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, ScrollableContainer, Vertical from textual.screen import ModalScreen from textual.theme import Theme from textual.widgets import ( ContentSwitcher, DataTable, Footer, Header, Input, OptionList, Sparkline, Static, ) from textual.widgets.option_list import Option from rich.style import Style from rich.text import Text BASE_URL = "http://localhost:4317" REFRESH_SECS = 30 SYS_REFRESH_SECS = 2.5 REPO = Path(__file__).parent.parent # sportsball (~/.config/sportsball/config.json) league keys → ESPN sport/league # paths used by this dashboard's sports source. Mirrors sportsball's league catalog. SPORTSBALL_LEAGUE_PATH = { "worldcup": "soccer/fifa.world", "mlb": "baseball/mlb", "nba": "basketball/nba", "wnba": "basketball/wnba", "nhl": "hockey/nhl", "nfl": "football/nfl", } PAGES = [ ("page-personal", "Personal"), ("page-news", "News"), ("page-system", "System"), ] THEME_NAMES = ["flexoki", "flexoki-dark", "humdrum", "humdrum-dark"] # Owner palettes — mirrored from Donuts/themes.py + shared app kit tokens.css _THEMES: dict[str, Theme] = { "flexoki": Theme( name="flexoki", primary="#205EA6", secondary="#24837B", warning="#AD8301", error="#AF3029", success="#66800B", accent="#5E409D", background="#FFFCF0", surface="#F2F0E5", panel="#CECDC3", foreground="#100F0F", dark=False, ), "flexoki-dark": Theme( name="flexoki-dark", primary="#4385BE", secondary="#3AA99F", warning="#D0A215", error="#D14D41", success="#879A39", accent="#8B7EC8", background="#100F0F", surface="#1C1B1A", panel="#282726", foreground="#CECDC3", dark=True, ), "humdrum": Theme( name="humdrum", primary="#0F80EA", secondary="#0054A6", warning="#985F00", error="#BA333C", success="#258200", accent="#7550C2", background="#F5F3EE", surface="#FFFFFF", panel="#C3BFB3", foreground="#2A2825", dark=False, ), "humdrum-dark": Theme( name="humdrum-dark", primary="#0F80EA", secondary="#63A8F7", warning="#CB9D2A", error="#ED807E", success="#75B966", accent="#AB92F0", background="#1F1D1A", surface="#282622", panel="#32302C", foreground="#E8E5DD", dark=True, ), } # ── Server bootstrap ────────────────────────────────────────────────────────── def ensure_server() -> bool: """Start the production Next.js server if not already responding.""" try: httpx.get(f"{BASE_URL}/api/health", timeout=2) return True except Exception: pass build_id = REPO / ".next" / "BUILD_ID" if not build_id.exists(): print(f"No production build at {REPO}. Run: pnpm build") return False print("Starting dashboard server…", flush=True) subprocess.Popen( ["pnpm", "start"], cwd=REPO, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) for _ in range(45): time.sleep(1) try: httpx.get(f"{BASE_URL}/api/health", timeout=1) return True except Exception: pass print("Server did not start in time.") return False # ── Fetching ────────────────────────────────────────────────────────────────── async def fetch_sources(force: bool = False) -> list[dict]: # Bare /api/sources returns cached snapshots only. fresh=1 refreshes stale # sources first; force=1 refreshes every source. Without a param, pressing # "r" would just re-read the same cache — the old refresh bug. q = "force=1" if force else "fresh=1" try: async with httpx.AsyncClient(timeout=20) as c: r = await c.get(f"{BASE_URL}/api/sources?{q}") r.raise_for_status() return r.json().get("sources", []) except Exception: return [] def payload(sources: list[dict], kind: str) -> dict | None: for s in sources: src = s.get("source") or {} snap = s.get("snapshot") or {} if src.get("kind") == kind and src.get("enabled") and snap.get("ok"): return snap.get("payload") return None def payloads(sources: list[dict], *kinds: str) -> list[dict]: out = [] for s in sources: src = s.get("source") or {} snap = s.get("snapshot") or {} if src.get("kind") in kinds and src.get("enabled") and snap.get("ok"): if p := snap.get("payload"): out.append(p) return out # ── Helpers ─────────────────────────────────────────────────────────────────── _7DAYS_MS = 7 * 24 * 60 * 60 * 1000 def to_local(iso: str) -> datetime: """Parse ISO string (may be UTC/tz-aware) and return local naive datetime.""" d = datetime.fromisoformat(iso) if d.tzinfo is not None: d = d.astimezone().replace(tzinfo=None) return d def within_7_days(iso: str | None) -> bool: if not iso: return True try: t = datetime.fromisoformat(iso).timestamp() * 1000 return t <= (datetime.now().timestamp() * 1000 + _7DAYS_MS) except Exception: return True def hm(iso: str) -> str: try: return to_local(iso).strftime("%-I:%M %p") except Exception: return iso[:5] if len(iso) >= 5 else iso def event_label(iso: str, all_day: bool) -> str: try: d = to_local(iso) is_today = d.date() == date.today() date_part = "Today" if is_today else d.strftime("%a %b %-d") return date_part if all_day else f"{date_part} {d.strftime('%-I:%M %p')}" except Exception: return iso[:16] def day_abbr(iso: str) -> str: try: return date.fromisoformat(iso).strftime("%a") except Exception: return iso[:3] def aqi_style(v: int | None) -> str: if v is None: return "dim" if v <= 50: return "green" if v <= 100: return "yellow" if v <= 150: return "dark_orange" if v <= 200: return "red" return "magenta" def uv_label(v: int | None) -> str: if v is None: return "" if v <= 2: return "Low" if v <= 5: return "Moderate" if v <= 7: return "High" if v <= 10: return "Very High" return "Extreme" # ── Renderers ───────────────────────────────────────────────────────────────── def render_weather(p: dict) -> Text: t = Text() units = p.get("units", "imperial") deg = "°C" if units == "metric" else "°F" wu = "km/h" if units == "metric" else "mph" cur = p.get("current", {}) for a in p.get("alerts", []): t.append(f"⚠ {a['event']}\n", style="bold red") t.append(f"{cur.get('temp', '—')}{deg}", style="bold yellow") t.append(f" {cur.get('text', '')}\n") details: list[str] = [] feels = cur.get("feelsLike") if feels is not None and abs(feels - (cur.get("temp") or feels)) >= 2: details.append(f"Feels {feels}{deg}") if cur.get("wind"): details.append(f"Wind {cur['wind']} {wu}") if cur.get("humidity") is not None: details.append(f"{cur['humidity']}% RH") if cur.get("uvIndex") is not None: details.append(f"UV {cur['uvIndex']} {uv_label(cur['uvIndex'])}") if details: t.append(" · ".join(details) + "\n", style="dim") if (aqi := p.get("aqi")) is not None: t.append(f"AQI {aqi} ", style=aqi_style(aqi)) t.append(f"{p.get('aqiCategory', '')}\n", style="dim") astro: list[str] = [] if p.get("sunrise"): astro.append(f"↑ {hm(p['sunrise'])}") if p.get("sunset"): astro.append(f"↓ {hm(p['sunset'])}") if p.get("moonPhase"): astro.append(f"{p.get('moonEmoji', '')} {p['moonPhase']}") if astro: t.append(" · ".join(astro) + "\n", style="dim") high_pollen = [pl for pl in p.get("pollen", []) if pl.get("level") != "Low"] if high_pollen: t.append("Pollen: " + " · ".join( f"{pl['label']} {pl['level']}" for pl in high_pollen ) + "\n", style="dim") t.append("\n") for d in p.get("daily", [])[:3]: t.append(f"{day_abbr(d.get('date', '')):<4}", style="bold dim") t.append(f" {d.get('max', '—')}° / {d.get('min', '—')}°") if prob := d.get("precipProb"): t.append(f" {prob}%", style="blue") t.append("\n") return t def render_calendar(p: dict) -> Text: events = [e for e in p.get("events", []) if within_7_days(e.get("start"))] if not events: return Text("No upcoming events.", style="dim italic") t = Text() for e in events: label = event_label(e.get("start", ""), e.get("allDay", False)) t.append(f"{label:<22} ", style="dim") t.append(f"{e.get('summary', '?')}\n") return t def render_todos(p: dict) -> Text: tasks = p.get("tasks", []) if not tasks: return Text("Nothing today.", style="dim italic") t = Text() for task in tasks: t.append("☐ ") t.append(task.get("title", "?")) if proj := task.get("project"): t.append(f" {proj}", style="dim") t.append("\n") return t def render_markets(p: dict) -> Text: quotes = p.get("quotes", []) if not quotes: return Text("No quotes.", style="dim italic") t = Text() for q in quotes: pct = q.get("changePct") price = q.get("price") t.append(f"{q.get('symbol', '?'):<6}", style="bold") if price is not None: t.append(f" {price:>12,.2f}") if pct is not None: color = "green" if pct >= 0 else "red" t.append(f" {'+'if pct>=0 else ''}{pct:.2f}%", style=color) t.append("\n") return t def _link(url: str) -> Style: safe = url.replace('"', "%22").replace("'", "%27") return Style(underline=True, meta={"@click": f'app.open_link("{safe}")'}) def render_news(ps: list[dict], cap: int = 12) -> Text: t, seen, n = Text(), set(), 0 for p in ps: for item in p.get("items", []): if n >= cap: break title = item.get("title", "?") if title in seen: continue seen.add(title) n += 1 url = item.get("link", "") t.append("• ", style="dim") t.append(f"{title}\n", style=_link(url) if url else "") if src := item.get("source"): t.append(f" {src}\n", style="dim") if n == 0: return Text("No headlines.", style="dim italic") return t def render_hackernews(p: dict, cap: int = 10) -> Text: stories = p.get("stories", [])[:cap] if not stories: return Text("No stories.", style="dim italic") t = Text() for s in stories: url = s.get("url") or "" t.append("• ", style="dim") t.append(f"{s.get('title', '?')}\n", style=_link(url) if url else "") if src := s.get("source"): t.append(f" {src}\n", style="dim") return t # Sort order within a tier: Active (live) → Finished → Upcoming (scheduled). def _game_order(g: dict) -> int: state = g.get("state") if state == "in": return 0 if state == "post": return 1 return 2 def _game_date(g: dict) -> str: """Leading date column — 'Now' while live, else the game's local date.""" if g.get("state") == "in": return "Now" st = g.get("startTime") if st: try: return to_local(st).strftime("%b %-d") except Exception: pass return "—" def _game_score(g: dict) -> tuple[str, str]: """Trailing score/status column for one game — (text, style).""" state = g.get("state") away_sc = g.get("awayScore") home_sc = g.get("homeScore") status = g.get("status", "") if state == "in": # Live: score + current period/inning score = f"{away_sc}–{home_sc}" if away_sc is not None and home_sc is not None else "—" return f"{score} {status}", "bold yellow" if state == "post": # Final: score + "F" score = f"{away_sc}–{home_sc}" if away_sc is not None and home_sc is not None else "—" final_tag = "F/OT" if "OT" in (status or "") else "F" return f"{score} {final_tag}", "dim" # Scheduled: start time of day (the date is in its own column) st = g.get("startTime") if st: try: return to_local(st).strftime("%-I:%M %p"), "dim" except Exception: pass return status, "dim" def _append_game_line(t: Text, g: dict) -> None: # date · teams · score score, score_style = _game_score(g) t.append(f" {_game_date(g):<7}", style="dim") t.append(f"{g.get('away', '?')} @ {g.get('home', '?')}") t.append(f" {score}\n", style=score_style) # Favorites are grouped by status, not league. state → section header. _FAV_SECTIONS = (("in", "Live"), ("post", "Last"), ("pre", "Next")) def render_sports(p: dict) -> Text: games = p.get("games", []) if not games: return Text("No games.", style="dim italic") # The module scopes to now (yesterday + today + a favorite's next game) and # tags favorites. favs = [g for g in games if g.get("favorite")] leagues = [g for g in games if not g.get("favorite")] t = Text() first_section = True # Favorites: by status (Live / Yesterday / Later Today), no league split. for state_key, label in _FAV_SECTIONS: section = [g for g in favs if (g.get("state") or "pre") == state_key] if not section: continue if not first_section: t.append("\n") first_section = False t.append(f"{label}\n", style="bold") for g in sorted(section, key=lambda g: g.get("startTime") or ""): _append_game_line(t, g) # Leagues: opted-in full slates, ordered by status and grouped by league. if leagues: if not first_section: t.append("\n") first_section = False t.append("Leagues\n", style="bold") cur_league = None for g in sorted(leagues, key=lambda g: (_game_order(g), g.get("startTime") or "")): lg = g.get("league", "").split("/")[-1].upper() if lg != cur_league: cur_league = lg t.append(f" {lg}\n", style="dim") _append_game_line(t, g) return t def render_uptime(p: dict) -> Text: sites = p.get("sites", []) if not sites: return Text("No sites configured.", style="dim italic") t = Text() for s in sites: ok = s.get("ok", False) url = s.get("url", "?") label = url.replace("https://", "").replace("http://", "").rstrip("/") status_style = "green" if ok else "bold red" status_icon = "●" if ok else "○" t.append(f"{status_icon} ", style=status_style) t.append(f"{label:<40}") if ms := s.get("ms"): t.append(f" {ms}ms", style="dim") elif not ok: if code := s.get("status"): t.append(f" HTTP {code}", style="red") else: t.append(" down", style="red") t.append("\n") return t def render_brief_category(cat: dict) -> Text: """One brief category for a News-page quadrant — the panel border carries the category name, so this just lists its stories (title + full summary).""" stories = cat.get("stories", []) if not stories: return Text("No stories.", style="dim italic") t = Text() for s in stories: url = s.get("link") or "" t.append("• ", style="dim") t.append(f"{s.get('title', '?')}\n", style=_link(url) if url else "") if summ := s.get("summary"): t.append(f" {summ}\n\n", style="dim") return t # ── System stats ────────────────────────────────────────────────────────────── def _duration(secs: float) -> str: secs = int(secs) d, rem = divmod(secs, 86400) h, rem = divmod(rem, 3600) m, s = divmod(rem, 60) if d: return f"{d}d {h}h {m}m" if h: return f"{h}h {m}m" if m: return f"{m}m {s}s" return f"{s}s" def _rate(bps: float | None) -> str: if bps is None: return "—" if bps >= 1024 * 1024: return f"{bps / (1024 * 1024):.1f} MB/s" if bps >= 1024: return f"{bps / 1024:.1f} KB/s" return f"{bps:.0f} B/s" def _gb(n: float) -> float: return n / 2**30 def _iops(n: float | None) -> str: return "—" if n is None else f"{n:.0f}/s" def _ms(n: float | None) -> str: return "—" if n is None else f"{n:.1f}ms" def _ping(host: str) -> float | None: """Round-trip ms to host via one ping, or None if unreachable/slow.""" try: out = subprocess.run( ["ping", "-c", "1", "-t", "1", host], capture_output=True, text=True, timeout=2, ).stdout if m := re.search(r"time=([\d.]+)", out): return float(m.group(1)) except Exception: pass return None def _default_gateway() -> str | None: """The LAN default-gateway IP (macOS `route`), or None.""" try: out = subprocess.run( ["route", "-n", "get", "default"], capture_output=True, text=True, timeout=1, ).stdout if m := re.search(r"gateway:\s*([\d.]+)", out): return m.group(1) except Exception: pass return None def _idle_seconds() -> float | None: """Seconds since last keyboard/mouse input, via IOKit's HIDIdleTime.""" try: out = subprocess.run( ["ioreg", "-c", "IOHIDSystem"], capture_output=True, text=True, timeout=3, ).stdout if m := re.search(r'"HIDIdleTime" = (\d+)', out): return int(m.group(1)) / 1e9 except Exception: pass return None class SystemStats: """Collects local host metrics. sample() is blocking — call off the UI thread.""" def __init__(self) -> None: psutil.cpu_percent(interval=None) # prime; next call returns a real delta psutil.cpu_percent(interval=None, percpu=True) # prime per-core too self.cpu_history: deque[float] = deque([0.0] * 60, maxlen=60) self._last_net = psutil.net_io_counters() self._last_net_t = time.monotonic() self._last_disk = psutil.disk_io_counters() self._gateway = _default_gateway() def sample(self) -> dict: cpu = psutil.cpu_percent(interval=None) self.cpu_history.append(cpu) percpu = psutil.cpu_percent(interval=None, percpu=True) net = psutil.net_io_counters() now = time.monotonic() dt = now - self._last_net_t down = up = disk_read = disk_write = None if dt > 0.5: down = max(0.0, (net.bytes_recv - self._last_net.bytes_recv) / dt) up = max(0.0, (net.bytes_sent - self._last_net.bytes_sent) / dt) self._last_net, self._last_net_t = net, now disk_io = psutil.disk_io_counters() disk_rops = disk_wops = disk_rlat = disk_wlat = None if dt > 0.5 and disk_io and self._last_disk: ld = self._last_disk disk_read = max(0.0, (disk_io.read_bytes - ld.read_bytes) / dt) disk_write = max(0.0, (disk_io.write_bytes - ld.write_bytes) / dt) drc = disk_io.read_count - ld.read_count dwc = disk_io.write_count - ld.write_count disk_rops = max(0.0, drc / dt) disk_wops = max(0.0, dwc / dt) # Avg per-op latency = time spent / ops over the interval. disk_rlat = (disk_io.read_time - ld.read_time) / drc if drc > 0 else 0.0 disk_wlat = (disk_io.write_time - ld.write_time) / dwc if dwc > 0 else 0.0 self._last_disk = disk_io lat_gw = _ping(self._gateway) if self._gateway else None lat_net = _ping("1.1.1.1") all_procs = [] nproc = nthreads = running = sleeping = 0 for pr in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info", "num_threads", "status"]): info = pr.info nproc += 1 nthreads += info.get("num_threads") or 0 st = info.get("status") if st == "running": running += 1 elif st == "sleeping": sleeping += 1 all_procs.append(info) by_cpu = sorted( (p for p in all_procs if p.get("cpu_percent") is not None), key=lambda i: i["cpu_percent"], reverse=True, )[:12] by_mem = sorted( (p for p in all_procs if p.get("memory_info")), key=lambda i: i["memory_info"].rss, reverse=True, )[:12] return { "cpu": cpu, "percpu": percpu, "load": psutil.getloadavg(), "cores": psutil.cpu_count() or 0, "mem": psutil.virtual_memory(), "swap": psutil.swap_memory(), "disk": psutil.disk_usage("/"), "down": down, "up": up, "lat_gw": lat_gw, "lat_net": lat_net, "disk_read": disk_read, "disk_write": disk_write, "disk_rops": disk_rops, "disk_wops": disk_wops, "disk_rlat": disk_rlat, "disk_wlat": disk_wlat, "battery": psutil.sensors_battery(), "uptime": time.time() - psutil.boot_time(), "idle": _idle_seconds(), "nproc": nproc, "nthreads": nthreads, "running": running, "sleeping": sleeping, "procs": by_cpu, "procs_mem": by_mem, "history": list(self.cpu_history), } def render_cpu(s: dict) -> Text: t = Text() t.append(f"{s['cpu']:.1f}", style="bold yellow") t.append(" %\n") l1, l5, l15 = s["load"] t.append(f"Load {l1:.2f} · {l5:.2f} · {l15:.2f}\n", style="dim") t.append(f"{s['cores']} cores\n", style="dim") t.append("\n") t.append(f"{s['nproc']} procs · {s['nthreads']} thr\n", style="dim") t.append(f"{s['running']} run · {s['sleeping']} sleep\n", style="dim") return t # Bar glyphs from empty→full, for the per-core meters. _CORE_BARS = "▁▂▃▄▅▆▇█" def render_cores(s: dict) -> Text: percpu = s.get("percpu") or [] if not percpu: return Text("—", style="dim italic") t = Text() for i, pct in enumerate(percpu): idx = min(len(_CORE_BARS) - 1, int(pct / 100 * len(_CORE_BARS))) style = "green" if pct < 50 else ("yellow" if pct < 85 else "red") t.append(f"{i} ", style="dim") t.append(_CORE_BARS[idx], style=style) t.append(f" {pct:>3.0f}% ", style="dim") if i % 2 == 1: t.append("\n") if len(percpu) % 2 == 1: t.append("\n") return t def render_memdisk(s: dict) -> Text: mem, disk, swap = s["mem"], s["disk"], s["swap"] t = Text() t.append(f"{mem.percent:.0f}", style="bold yellow") t.append(" % mem\n") t.append(f"{_gb(mem.used):.1f} / {_gb(mem.total):.0f} GB\n", style="dim") if swap.total > 0: t.append(f"{swap.percent:.0f}", style="bold") t.append(" % swap\n") t.append(f"{_gb(swap.used):.1f} / {_gb(swap.total):.1f} GB\n", style="dim") t.append("\n") t.append(f"{_gb(disk.free):.0f} GB", style="bold") t.append(" disk free\n") t.append(f"{_gb(disk.used):.0f} GB used · {disk.percent:.0f}%\n", style="dim") return t def _lat(ms: float | None) -> tuple[str, str]: if ms is None: return "—", "dim" style = "green" if ms < 50 else ("yellow" if ms < 150 else "red") return f"{ms:.0f}ms", style def render_net(s: dict) -> Text: t = Text() t.append("Net\n", style="dim") t.append(" ↓ ", style="green") t.append(f"{_rate(s['down'])}\n", style="bold") t.append(" ↑ ", style="blue") t.append(f"{_rate(s['up'])}\n", style="bold") gl, gs = _lat(s.get("lat_gw")) nl, ns = _lat(s.get("lat_net")) t.append(" gw ", style="dim") t.append(gl, style=gs) t.append(" · net ", style="dim") t.append(f"{nl}\n", style=ns) t.append("Disk\n", style="dim") t.append(" R ", style="green") t.append(f"{_rate(s.get('disk_read'))}\n", style="bold") t.append(f" {_iops(s.get('disk_rops'))} · {_ms(s.get('disk_rlat'))}\n", style="dim") t.append(" W ", style="blue") t.append(f"{_rate(s.get('disk_write'))}\n", style="bold") t.append(f" {_iops(s.get('disk_wops'))} · {_ms(s.get('disk_wlat'))}\n", style="dim") return t def render_power(s: dict) -> Text: t = Text() if batt := s["battery"]: t.append(f"{batt.percent:.0f}", style="bold yellow") t.append(" % battery") if batt.power_plugged: t.append(" ⚡", style="yellow") t.append("\n") if batt.power_plugged or batt.secsleft == psutil.POWER_TIME_UNLIMITED: t.append("on AC\n", style="dim") elif batt.secsleft == psutil.POWER_TIME_UNKNOWN: t.append("—\n", style="dim") else: t.append(f"{_duration(batt.secsleft)} left\n", style="dim") else: t.append("No battery\n", style="dim italic") t.append("\n") t.append("Up ", style="dim") t.append(_duration(s["uptime"])) t.append("\n") if (idle := s["idle"]) is not None: t.append("Away ", style="dim") t.append(_duration(idle)) t.append("\n") return t # ── Widget ──────────────────────────────────────────────────────────────────── class DashPanel(ScrollableContainer): DEFAULT_CSS = """ DashPanel { border: round $panel; border-title-color: $primary; padding: 0 1; height: 100%; scrollbar-size: 1 1; } DashPanel:focus { border: round $accent; border-title-color: $accent; } """ def __init__(self, title: str, panel_id: str, **kwargs): super().__init__(id=panel_id, **kwargs) self.border_title = title self._body = Static("") def compose(self) -> ComposeResult: yield self._body def set_content(self, content: Text) -> None: self._body.update(content) # ── kk overlay (native dash menu) ───────────────────────────────────────────── # Entries safe to run captured (no TTY needed) — everything else suspends the # TUI and runs `dash ""` interactively (gum prompts, fzf pickers, TUIs). KK_CAPTURED = { "day", "today", "week", "todos", "tasks", "weather", "claude-status", "installed", "yesterday-note", "morning-paper --print", "am play", "am pause", "am next", "am prev", "am now", } # Entries whose only interactivity is one text prompt — replicated natively. # key → (placeholder, command builder, allow_empty) def _scratch_cmd(text: str) -> str: stamp = datetime.now().strftime("%I:%M %p") return f"daily-append {shlex.quote(f'- `{stamp}` – {text}')}" KK_INPUT: dict[str, tuple[str, object, bool]] = { "scratch": ("what's on your mind?", _scratch_cmd, False), "note": ("the thought (AI names/files it)", lambda t: f"note {shlex.quote(t)}", False), "dict": ("word", lambda t: f"dict {shlex.quote(t)}", False), "am search": ("search library", lambda t: f"am search {shlex.quote(t)}", False), "am vol": ("volume 0-100 (blank = show)", lambda t: f"am vol {shlex.quote(t)}" if t else "am vol", True), } # Long-running captured commands (claude generation, puppeteer) get more rope. KK_SLOW_TIMEOUT = 600 def kk_choices(key: str) -> list[tuple[str, str]] | None: """(command, label) pairs for entries that branch on a small pick.""" if key in ("weeknotes", "monthnotes", "quarternotes", "yearnotes"): period = key.removesuffix("notes") return [(key, f"this {period}"), (f"{key} last", f"last {period}")] if key == "dayreview": return [("_dayreview", "yesterday"), ("_dayreview today", "today")] if key == "daydata": return [("_daydata", "yesterday"), ("_daydata today", "today")] if key == "zine": now = datetime.now() this = now.strftime("%Y-%m") nxt = f"{now.year + (now.month == 12):04d}-{(now.month % 12) + 1:02d}" return [(f"_zine {this}", "this month"), (f"_zine {nxt}", "next month")] return None def load_dash_entries() -> list[tuple[str, str]]: """Parse the dash() entries array from ~/.zshrc → (key, label) pairs.""" try: text = (Path.home() / ".zshrc").read_text() except Exception: return [] body = re.search(r"^dash\(\)\s*\{(.+?)^\}", text, re.S | re.M) if not body: return [] arr = re.search(r"entries=\((.*?)\n\s*\)", body.group(1), re.S) if not arr: return [] out: list[tuple[str, str]] = [] for line in arr.group(1).splitlines(): if m := re.match(r'^\s*"([^|"]+)\|(.*)"\s*$', line): key, label = m.group(1), m.group(2) if key == "dashboard": # that's this app — skip continue out.append((key, label)) return out class KkFilterInput(Input): def on_key(self, event) -> None: if event.key == "escape": event.stop() event.prevent_default() self.screen.dismiss(None) elif event.key in ("up", "down"): event.stop() event.prevent_default() lst = self.screen.query_one(OptionList) if event.key == "up": lst.action_cursor_up() else: lst.action_cursor_down() class KkScreen(ModalScreen[str | None]): """Native fuzzy-filter version of the dash/kk gum menu.""" BINDINGS = [Binding("escape", "close", "Close", show=False)] DEFAULT_CSS = """ KkScreen { align: center middle; } #kk-box { width: 84; height: auto; max-height: 80%; background: $surface; border: round $accent; border-title-color: $accent; padding: 0 1; } #kk-box Input { border: round $panel; } #kk-list { height: auto; max-height: 24; background: $surface; scrollbar-size: 1 1; } """ def __init__(self, entries: list[tuple[str, str]], title: str = "kk — pick a command"): super().__init__() self._entries = entries self._title = title def compose(self) -> ComposeResult: with Vertical(id="kk-box"): yield KkFilterInput(placeholder="filter…", id="kk-filter") yield OptionList(id="kk-list") def on_mount(self) -> None: self.query_one("#kk-box", Vertical).border_title = self._title self._refilter("") self.query_one("#kk-filter", KkFilterInput).focus() def _refilter(self, query: str) -> None: lst = self.query_one(OptionList) lst.clear_options() words = query.lower().split() for key, label in self._entries: hay = f"{key} {label}".lower() if all(w in hay for w in words): lst.add_option(Option(Text(label, no_wrap=True), id=key)) if lst.option_count: lst.highlighted = 0 def on_input_changed(self, event: Input.Changed) -> None: self._refilter(event.value) def on_input_submitted(self, event: Input.Submitted) -> None: lst = self.query_one(OptionList) if lst.option_count and lst.highlighted is not None: self.dismiss(lst.get_option_at_index(lst.highlighted).id) def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: self.dismiss(event.option.id) def action_close(self) -> None: self.dismiss(None) class KkPromptInput(Input): def on_key(self, event) -> None: if event.key == "escape": event.stop() event.prevent_default() self.screen.dismiss(None) class KkInputScreen(ModalScreen[str | None]): """Single text prompt — native replacement for an entry's gum input.""" BINDINGS = [Binding("escape", "close", "Close", show=False)] DEFAULT_CSS = """ KkInputScreen { align: center middle; } #kkp-box { width: 84; height: auto; background: $surface; border: round $accent; border-title-color: $accent; padding: 0 1; } #kkp-box Input { border: round $panel; } """ def __init__(self, title: str, placeholder: str, allow_empty: bool = False): super().__init__() self._title = title self._placeholder = placeholder self._allow_empty = allow_empty def compose(self) -> ComposeResult: with Vertical(id="kkp-box"): yield KkPromptInput(placeholder=self._placeholder, id="kkp-input") def on_mount(self) -> None: self.query_one("#kkp-box", Vertical).border_title = self._title self.query_one("#kkp-input", KkPromptInput).focus() def on_input_submitted(self, event: Input.Submitted) -> None: value = event.value.strip() if value or self._allow_empty: self.dismiss(value) else: self.dismiss(None) def action_close(self) -> None: self.dismiss(None) # ── Command bar ─────────────────────────────────────────────────────────────── class CommandInput(Input): """Input with Esc-to-close and up/down history, delegated to the app.""" def on_key(self, event) -> None: if event.key == "escape": event.stop() event.prevent_default() self.app.hide_command_bar() elif event.key == "up": event.stop() event.prevent_default() self.app.history_nav(-1) elif event.key == "down": event.stop() event.prevent_default() self.app.history_nav(1) class CommandBar(Vertical): DEFAULT_CSS = """ CommandBar { height: auto; display: none; background: $surface; padding: 0 1; } CommandBar.visible { display: block; } CommandBar Input { border: round $accent; } CommandBar #cmd-output-wrap { height: auto; max-height: 12; scrollbar-size: 1 1; } CommandBar #cmd-output { padding: 0 2; } """ def compose(self) -> ComposeResult: yield CommandInput( id="cmd-input", placeholder="command… (zsh, aliases work · !cmd = interactive in terminal · Esc = close)", ) with ScrollableContainer(id="cmd-output-wrap"): yield Static("", id="cmd-output") # ── App ─────────────────────────────────────────────────────────────────────── class DashboardApp(App): TITLE = "Personal Dashboard" BINDINGS = [ ("1", "page('page-personal')", "Personal"), ("2", "page('page-news')", "News"), ("3", "page('page-system')", "System"), Binding("[", "cycle_page(-1)", "Prev page", show=False), Binding("]", "cycle_page(1)", "Next page", show=False), Binding(":", "command_bar", "Cmd", key_display=":"), ("k", "kk_menu", "kk"), ("r", "refresh", "Refresh"), ("t", "next_theme", "Theme"), ("q", "quit", "Quit"), ] CSS = """ Screen { background: $background; } ContentSwitcher { height: 1fr; } #page-personal, #page-news, #page-system { height: 100%; } .row { height: 1fr; } .row-tall { height: 2fr; } #weather { width: 1.75fr; } #uptime { width: 0.8fr; } #markets { width: 1.25fr; } #todos { width: 1.6fr; } #calendar { width: 1.4fr; } #sports { width: 2fr; } #news { width: 2fr; } #hackernews { width: 1.5fr; } #brief-0, #brief-1, #brief-2, #brief-3 { width: 1fr; } #sys-cpu, #sys-memdisk, #sys-net, #sys-power { width: 1fr; } .sys-spark-row { height: 8; } #sys-sparkline { width: 1.6fr; height: 100%; margin: 0 0; padding: 0 1; border: round $panel; border-title-color: $primary; color: $accent; } #sys-cores { width: 1fr; } #sys-procs, #sys-procs-mem { width: 1fr; height: 100%; border: round $panel; border-title-color: $primary; scrollbar-size: 1 1; } """ _theme_idx: int = 0 _page_id: str = "page-personal" _connected: bool = False _sports_id: str | None = None _sports_live: bool = False _last_refresh: datetime | None = None def __init__(self, **kwargs): super().__init__(**kwargs) self._cmd_history: list[str] = [] self._hist_idx = 0 def on_mount(self) -> None: for t in _THEMES.values(): self.register_theme(t) self.theme = THEME_NAMES[self._theme_idx] self._sys = SystemStats() self._sys_timer = self.set_interval( SYS_REFRESH_SECS, self.refresh_system, pause=True ) for tid, cols in ( ("#sys-procs", ("PID", "Name", "CPU %", "Mem")), ("#sys-procs-mem", ("PID", "Name", "Mem", "CPU %")), ): tbl = self._q(tid, DataTable) tbl.add_columns(*cols) tbl.cursor_type = "none" self.refresh_data() self.set_interval(REFRESH_SECS, self.refresh_data) self.set_interval(60, self.poll_sports_live) self.sync_favorites() def _q(self, selector: str, expect_type): """query_one against the main screen — app.query_one resolves against the TOP of the screen stack, so timer/worker lookups crash with NoMatches while a modal (kk overlay) is open.""" return self.screen_stack[0].query_one(selector, expect_type) def compose(self) -> ComposeResult: yield Header() with ContentSwitcher(initial="page-personal", id="switcher"): with Vertical(id="page-personal"): with Horizontal(classes="row"): yield DashPanel("Weather", "weather") yield DashPanel("Uptime", "uptime") yield DashPanel("Markets", "markets") with Horizontal(classes="row"): yield DashPanel("Agenda", "todos") yield DashPanel("Calendar", "calendar") with Horizontal(classes="row row-tall"): yield DashPanel("Scores", "sports") yield DashPanel("Headlines", "news") yield DashPanel("Hacker News", "hackernews") with Vertical(id="page-news"): # 2×2 of the four news briefs from screamer — one per quadrant. # Titles are set per-refresh from the brief category names. with Horizontal(classes="row"): yield DashPanel("Brief", "brief-0") yield DashPanel("Brief", "brief-1") with Horizontal(classes="row"): yield DashPanel("Brief", "brief-2") yield DashPanel("Brief", "brief-3") with Vertical(id="page-system"): with Horizontal(classes="row"): yield DashPanel("CPU", "sys-cpu") yield DashPanel("Memory · Disk", "sys-memdisk") yield DashPanel("Network · Disk I/O", "sys-net") yield DashPanel("Power · Uptime", "sys-power") with Horizontal(classes="sys-spark-row"): spark = Sparkline([], id="sys-sparkline", summary_function=max) spark.border_title = "CPU history" yield spark yield DashPanel("Cores", "sys-cores") with Horizontal(classes="row row-tall"): procs = DataTable(id="sys-procs") procs.border_title = "Top by CPU" yield procs procs_mem = DataTable(id="sys-procs-mem") procs_mem.border_title = "Top by Memory" yield procs_mem yield CommandBar(id="cmd-bar") yield Footer() @work(exclusive=True) async def refresh_data(self, force: bool = False) -> None: sources = await fetch_sources(force) connected = bool(sources) no_data = Text("No data.", style="dim italic") def panel(pid: str) -> DashPanel: return self._q(f"#{pid}", DashPanel) panel("weather").set_content( render_weather(p) if (p := payload(sources, "weather")) else no_data ) panel("calendar").set_content( render_calendar(p) if (p := payload(sources, "calendar")) else no_data ) panel("todos").set_content( render_todos(p) if (p := payload(sources, "todos")) else no_data ) panel("markets").set_content( render_markets(p) if (p := payload(sources, "markets")) else no_data ) panel("sports").set_content( render_sports(p) if (p := payload(sources, "sports")) else no_data ) news_ps = payloads(sources, "news") panel("news").set_content( render_news(news_ps) if news_ps else no_data ) hn_p = payload(sources, "hackernews") panel("hackernews").set_content( render_hackernews(hn_p) if hn_p else no_data ) # News page: one brief category per quadrant (config order). briefs_p = payload(sources, "briefs") brief_cats = (briefs_p or {}).get("categories", []) for i in range(4): pnl = panel(f"brief-{i}") if i < len(brief_cats): cat = brief_cats[i] pnl.border_title = cat.get("name", "Brief") pnl.set_content(render_brief_category(cat)) else: pnl.border_title = "—" pnl.set_content(no_data) panel("uptime").set_content( render_uptime(p) if (p := payload(sources, "uptime")) else no_data ) sports_src = next( (s for s in sources if (s.get("source") or {}).get("kind") == "sports"), None, ) if sports_src: self._sports_id = (sports_src.get("source") or {}).get("id") snap = sports_src.get("snapshot") or {} games = (snap.get("payload") or {}).get("games", []) if snap.get("ok") else [] self._sports_live = any(g.get("state") == "in" for g in games) else: self._sports_id = None self._sports_live = False if connected: self._last_refresh = datetime.now() self._connected = connected self._update_subtitle() @work(exclusive=True, group="sports") async def poll_sports_live(self) -> None: # While any game is in progress, force-refresh ONLY the sports source so # live scores tick without re-fetching every other source. Dormant when # nothing is live. if not (self._sports_live and self._sports_id): return try: async with httpx.AsyncClient(timeout=15) as c: r = await c.post(f"{BASE_URL}/api/sources/{self._sports_id}/refresh") r.raise_for_status() state = r.json().get("source") or {} except Exception: return snap = state.get("snapshot") or {} p = snap.get("payload") if snap.get("ok") else None self._q("#sports", DashPanel).set_content( render_sports(p) if p else Text("No games.", style="dim italic") ) games = (p or {}).get("games", []) self._sports_live = any(g.get("state") == "in" for g in games) @work(exclusive=True, group="favsync") async def sync_favorites(self) -> None: # On app open, mirror sportsball's favorite teams into this dashboard's # sports source config (once — not per fetch). Silently no-op if the file # is absent, has no favorites, or the server is unreachable. cfg = Path.home() / ".config" / "sportsball" / "config.json" try: favs = json.loads(cfg.read_text()).get("favorites") or [] except Exception: return teams = [] for f in favs: if not isinstance(f, dict): continue path = SPORTSBALL_LEAGUE_PATH.get(f.get("league")) team = f.get("abbr") or f.get("name") if path and team: teams.append({"league": path, "team": team}) if not teams: return try: async with httpx.AsyncClient(timeout=15) as c: r = await c.get(f"{BASE_URL}/api/sources") r.raise_for_status() src = next( (s for s in r.json().get("sources", []) if (s.get("source") or {}).get("kind") == "sports"), None, ) if not src: return sid = (src.get("source") or {}).get("id") cur = (src.get("source") or {}).get("config") or {} await c.patch( f"{BASE_URL}/api/sources/{sid}", json={"enabled": True, "config": {**cur, "teams": teams}}, ) except Exception: return self.refresh_data(force=True) @work(exclusive=True, thread=True, group="sys") def refresh_system(self) -> None: stats = self._sys.sample() self.call_from_thread(self._apply_system, stats) def _apply_system(self, s: dict) -> None: def panel(pid: str) -> DashPanel: return self._q(f"#{pid}", DashPanel) panel("sys-cpu").set_content(render_cpu(s)) panel("sys-memdisk").set_content(render_memdisk(s)) panel("sys-net").set_content(render_net(s)) panel("sys-power").set_content(render_power(s)) panel("sys-cores").set_content(render_cores(s)) self._q("#sys-sparkline", Sparkline).data = s["history"] def mem_lbl(pr: dict) -> str: mem = pr.get("memory_info") return f"{mem.rss / 2**20:.0f} MB" if mem else "—" cpu_table = self._q("#sys-procs", DataTable) cpu_table.clear(columns=False) for pr in s["procs"]: cpu_table.add_row( str(pr["pid"]), (pr.get("name") or "?")[:40], f"{pr['cpu_percent']:.1f}", mem_lbl(pr), ) mem_table = self._q("#sys-procs-mem", DataTable) mem_table.clear(columns=False) for pr in s["procs_mem"]: mem_table.add_row( str(pr["pid"]), (pr.get("name") or "?")[:40], mem_lbl(pr), f"{pr.get('cpu_percent') or 0:.1f}", ) def _update_subtitle(self) -> None: refreshed = self._last_refresh.strftime("%-I:%M:%S %p") if self._last_refresh else "—" dot = "●" if self._connected else "○ offline" page_name = dict(PAGES)[self._page_id] self.sub_title = f"{dot} refreshed {refreshed} · {page_name} · {THEME_NAMES[self._theme_idx]}" def action_page(self, page_id: str) -> None: if page_id == self._page_id: return self._q("#switcher", ContentSwitcher).current = page_id self._page_id = page_id if page_id == "page-system": self._sys_timer.resume() self.refresh_system() else: self._sys_timer.pause() self._update_subtitle() def action_cycle_page(self, delta: int) -> None: ids = [pid for pid, _ in PAGES] idx = (ids.index(self._page_id) + delta) % len(ids) self.action_page(ids[idx]) # ── Command bar ────────────────────────────────────────────────────────── def action_command_bar(self) -> None: bar = self._q("#cmd-bar", CommandBar) bar.add_class("visible") self._q("#cmd-input", CommandInput).focus() def hide_command_bar(self) -> None: self._q("#cmd-bar", CommandBar).remove_class("visible") self.set_focus(None) def action_kk_menu(self) -> None: entries = load_dash_entries() if not entries: self.notify("couldn't parse dash entries from ~/.zshrc", severity="error") return def picked(key: str | None) -> None: if key: self._kk_dispatch(key) self.push_screen(KkScreen(entries), picked) def _kk_run(self, cmd: str, timeout: int = 60) -> None: self._set_cmd_output(Text(f"$ {cmd}\nrunning…", style="dim")) self.run_command(cmd, timeout=timeout) def _kk_dispatch(self, key: str) -> None: # one text prompt → command (scratch, note, dict, am search/vol) if key in KK_INPUT: placeholder, build, allow_empty = KK_INPUT[key] def submitted(value: str | None) -> None: if value is not None: self._kk_run(build(value), timeout=KK_SLOW_TIMEOUT) self.push_screen(KkInputScreen(key, placeholder, allow_empty), submitted) return # small fixed choice → command (periodic notes, dayreview, daydata, zine) if choices := kk_choices(key): def chose(cmd: str | None) -> None: if cmd: self._kk_run(cmd, timeout=KK_SLOW_TIMEOUT) self.push_screen(KkScreen(choices, title=key), chose) return # dynamic list pick if key == "am playlist": self._kk_playlist_flow() return if key in KK_CAPTURED: timeout = KK_SLOW_TIMEOUT if key == "morning-paper --print" else 60 self._kk_run(key, timeout=timeout) return # real TUIs / complex gum flows (task, event, md, vault, newsboat, …) self._run_interactive(f"dash {shlex.quote(key)}") @work(thread=True, group="cmd", exclusive=True) def _kk_playlist_flow(self) -> None: try: res = subprocess.run( ["zsh", "-ic", "am playlists"], capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL, start_new_session=True, ) names = [ln.strip() for ln in res.stdout.splitlines() if ln.strip()] except Exception: names = [] self.call_from_thread(self._kk_playlist_pick, names) def _kk_playlist_pick(self, names: list[str]) -> None: if not names: self.notify("no playlists found", severity="warning") return def chose(name: str | None) -> None: if name: self._kk_run(f"am playlist {shlex.quote(name)}") choices = [(n, n) for n in names] self.push_screen(KkScreen(choices, title="pick a playlist"), chose) def history_nav(self, delta: int) -> None: if not self._cmd_history: return self._hist_idx = max(0, min(len(self._cmd_history), self._hist_idx + delta)) inp = self._q("#cmd-input", CommandInput) if self._hist_idx == len(self._cmd_history): inp.value = "" else: inp.value = self._cmd_history[self._hist_idx] inp.cursor_position = len(inp.value) def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id != "cmd-input": return cmd = event.value.strip() event.input.value = "" if not cmd: self.hide_command_bar() return self._cmd_history.append(cmd) self._hist_idx = len(self._cmd_history) if cmd.startswith("!"): self._run_interactive(cmd[1:].strip() or "dash") else: self._set_cmd_output(Text(f"$ {cmd}\nrunning…", style="dim")) self.run_command(cmd) def _run_interactive(self, cmd: str) -> None: """Suspend the TUI and run cmd with the real terminal (gum menus, TUIs).""" with self.suspend(): subprocess.run(["zsh", "-ic", cmd]) self.hide_command_bar() self.notify(f"! {cmd}", title="ran in terminal", timeout=3) self.refresh() self.refresh_data() _ZSH_NOISE = re.compile( r"can't change option|stdin isn't a terminal|no job control|" r"inappropriate ioctl|not a terminal" ) @work(exclusive=True, thread=True, group="cmd") def run_command(self, cmd: str, timeout: int = 60) -> None: try: # stdin=DEVNULL + new session: no controlling TTY, so interactive # zsh (needed for aliases/functions) can't grab the terminal or # SIGTTIN-suspend the dashboard's process group. res = subprocess.run( ["zsh", "-ic", cmd], capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL, start_new_session=True, ) raw = (res.stdout + res.stderr).strip() out = "\n".join( ln for ln in raw.splitlines() if not self._ZSH_NOISE.search(ln) ).strip() code = res.returncode except subprocess.TimeoutExpired: out, code = f"timed out after {timeout}s (interactive? try !{cmd})", 1 self.call_from_thread(self._finish_cmd, cmd, out, code) def _finish_cmd(self, cmd: str, out: str, code: int) -> None: lines = out.splitlines() if code == 0 and len(lines) <= 3: # quick success — toast it and get out of the way self.hide_command_bar() self._set_cmd_output(Text("")) msg = out if out else "done" self.notify(msg[:200], title=f"✓ {cmd}"[:60], timeout=4) else: # long output worth reading, or a failure — show it in the bar # (kk picks run with the bar hidden, so make sure it's visible) text = Text() text.append(f"$ {cmd}\n", style="bold") text.append(Text.from_ansi(out) if out else Text("(no output)", style="dim italic")) if code != 0: text.append(f"\nexit {code}", style="bold red") self._set_cmd_output(text) self._q("#cmd-bar", CommandBar).add_class("visible") self.refresh() # full repaint clears any terminal residue self.refresh_data() def _set_cmd_output(self, text: Text) -> None: self._q("#cmd-output", Static).update(text) # ── Actions ────────────────────────────────────────────────────────────── def action_refresh(self) -> None: self.refresh_data(force=True) if self._page_id == "page-system": self.refresh_system() def action_next_theme(self) -> None: self._theme_idx = (self._theme_idx + 1) % len(THEME_NAMES) self.theme = THEME_NAMES[self._theme_idx] self._update_subtitle() def action_open_link(self, url: str) -> None: self.open_url(url) if __name__ == "__main__": ensure_server() DashboardApp().run()