Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0e4690703 |
@@ -0,0 +1,2 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,46 @@
|
||||
# Fenris TUI information-architecture PROTOTYPE (throwaway)
|
||||
|
||||
**This is throwaway code answering [ticket #3](https://git.bongbetic.com/xavierk/Fenris/issues/3).** It is not the redesign, reads nothing real, and never ships. Branch: `prototype/tui-information-architecture`.
|
||||
|
||||
## Question
|
||||
|
||||
What screen hierarchy, navigation, and action model makes Fenris's projection contract (ADR 0002 §13), the four separate service facts (ADR 0003 §8), warming-up, unexplained gaps, and changing habits understandable in a keyboard-first terminal?
|
||||
|
||||
## Run (one command)
|
||||
|
||||
```sh
|
||||
./run
|
||||
```
|
||||
|
||||
(creates `.venv` and installs `textual` on first use)
|
||||
|
||||
## What to flip through
|
||||
|
||||
**Variants (← / →)** — three structurally different answers, not restylings:
|
||||
|
||||
| Key | Variant | Idea |
|
||||
|-----|---------|------|
|
||||
| A | **Panes** | everything on one dense screen, btop-style; no navigation, panes are zones |
|
||||
| B | **Pages** | persistent three-fact header (lifespan · confidence · freshness) + pages 1–5 |
|
||||
| C | **Ledger** | one scrolling document in reading order, headline sentence first |
|
||||
|
||||
**States (s)** — same variants, six shapes of the contract:
|
||||
|
||||
1. steady · Supported (with one unexplained 3-hour gap)
|
||||
2. warming up · Limited (11 of 14 days)
|
||||
3. habit changed · Limited (regime 6 days old, scenario spread visible)
|
||||
4. stale · Supported→Limited (last collect FAILED, 61 h old)
|
||||
5. no baseline · Unavailable (wear too coarse to imply endurance)
|
||||
6. paused · Limited (period closed by deliberate disable)
|
||||
|
||||
**Actions** — `p` pause (asks confirmation) · `r` resume (doesn't) · `c` collect now (synchronous outcome). Each suspends the TUI and runs `polkit_stub.py` on the real terminal: this validates the tty passthrough ADR 0003 requires for the polkit prompt. Results land in the tty log (variant B service page; every variant's log is the same list).
|
||||
|
||||
## What to react to
|
||||
|
||||
- Which variant's hierarchy matches how you think about the drive? (Mixing — "header from B, density of A" — is a valid answer and the point.)
|
||||
- Are the four service facts separable at a glance?
|
||||
- Do confidence states + contributing facts read as evidence, not as a percentage?
|
||||
- Is the pause confirmation the right amount of friction?
|
||||
- Did the polkit tty stub actually prompt in your terminal? (That's the mechanism check.)
|
||||
|
||||
`screenshots/` holds headless captures (`smoke_test.py`) of each variant at 80×24 and 140×40.
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""STUB polkit-agent stand-in for the Fenris TUI prototype (throwaway).
|
||||
|
||||
Runs attached to the real terminal while the Textual app is suspended, exactly
|
||||
where the platform polkit agent would prompt for `com.bongbetic.fenris.monitor`.
|
||||
Accepts any password; the point is validating tty passthrough, not auth.
|
||||
"""
|
||||
import getpass
|
||||
import sys
|
||||
import time
|
||||
|
||||
op = sys.argv[1] if len(sys.argv) > 1 else "unknown"
|
||||
print("=" * 56)
|
||||
print(" polkit STUB · com.bongbetic.fenris.monitor")
|
||||
print(f" operation: {op}")
|
||||
print(" Authentication required to manage Fenris monitoring")
|
||||
print("=" * 56)
|
||||
try:
|
||||
getpass.getpass(" password (anything works): ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n(cancelled — operation not performed)")
|
||||
sys.exit(1)
|
||||
time.sleep(0.6) # pretend systemctl + monitoring-period bookkeeping
|
||||
print(f" fenris-monitor {op}: done")
|
||||
try:
|
||||
input(" [press Enter to return to the TUI] ")
|
||||
except EOFError:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1 @@
|
||||
textual>=0.60
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# PROTOTYPE runner — throwaway, see README.md
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
if [ ! -x .venv/bin/python ]; then
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv venv -q .venv
|
||||
uv pip install -q --python .venv/bin/python -r requirements.txt
|
||||
else
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip -q install -r requirements.txt
|
||||
fi
|
||||
fi
|
||||
exec .venv/bin/python tui_prototype.py
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,67 @@
|
||||
# Headless smoke test for the prototype: drives every variant × state through
|
||||
# Textual's test pilot, exports SVG screenshots, and asserts contract strings render.
|
||||
import asyncio, inspect, os, sys
|
||||
os.environ["FENRIS_PROTOTYPE_NO_TTY"] = "1"
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from tui_prototype import FenrisPrototypeApp, VARIANTS
|
||||
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
async def snap(app, name):
|
||||
r = app.export_screenshot()
|
||||
if inspect.isawaitable(r):
|
||||
r = await r
|
||||
with open(os.path.join(OUT, name + ".svg"), "w") as f:
|
||||
f.write(r)
|
||||
|
||||
async def main():
|
||||
checks = []
|
||||
for size in [(80, 24), (140, 40)]:
|
||||
app = FenrisPrototypeApp()
|
||||
async with app.run_test(size=size) as pilot:
|
||||
await pilot.pause()
|
||||
for i, (key, _name) in enumerate(VARIANTS):
|
||||
if i:
|
||||
await pilot.press("right"); await pilot.pause()
|
||||
tag = f"v{key}_{size[0]}x{size[1]}"
|
||||
await snap(app, tag + "_steady")
|
||||
# rotate states on variant B
|
||||
await pilot.press("left"); await pilot.pause() # back to A
|
||||
await pilot.press("right"); await pilot.pause() # B
|
||||
for st in ["warming", "changed", "stale", "nobaseline", "paused", "steady"]:
|
||||
await pilot.press("s"); await pilot.pause()
|
||||
await snap(app, f"vB_{size[0]}x{size[1]}_{st}")
|
||||
# pages on B
|
||||
for k in ["1", "2", "3", "4", "5"]:
|
||||
await pilot.press(k); await pilot.pause()
|
||||
body = app.query_one("#vB-service").content
|
||||
checks.append(("service facts", "boot enablement" in str(body) and "freshness" in str(body)))
|
||||
# pause flow: confirm modal, y, stub skipped headless -> state becomes paused? (skip branch)
|
||||
await pilot.press("4"); await pilot.pause()
|
||||
await pilot.press("p"); await pilot.pause()
|
||||
await pilot.press("n"); await pilot.pause() # cancel, no state change
|
||||
head = str(app.query_one("#vB-overview").content)
|
||||
checks.append(("confidence renders", "Projection confidence" in head))
|
||||
# disclosure modal
|
||||
await pilot.press("d"); await pilot.pause()
|
||||
await pilot.press("escape"); await pilot.pause()
|
||||
steady = FenrisPrototypeApp()
|
||||
async with steady.run_test(size=(140, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
doc = str(steady.query_one("#vC-doc").content)
|
||||
checks += [
|
||||
("headline", "Usage-adjusted theoretical lifespan" in doc),
|
||||
("scenario range", "Scenario range" in doc),
|
||||
("wear line", "Vendor wear" in doc),
|
||||
("gap marker", "unexplained gap" in doc),
|
||||
]
|
||||
failed = [n for n, ok in checks if not ok]
|
||||
print("CHECKS:", "all ok" if not failed else f"FAILED: {failed}")
|
||||
for n, ok in checks:
|
||||
print(f" {'ok ' if ok else 'FAIL'} {n}")
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,573 @@
|
||||
# PROTOTYPE (THROWAWAY) — Fenris TUI information-architecture prototype.
|
||||
# Question (ticket #3): what screen hierarchy, navigation, and action model makes
|
||||
# the projection contract (ADR 0002 §13) and the service facts (ADR 0003 §8)
|
||||
# understandable in a keyboard-first terminal?
|
||||
# Plan: three structurally different variants (A Panes / B Pages / C Ledger),
|
||||
# switchable live with ←/→, plus a scenario rotator (s) that drives the same
|
||||
# variants through warming-up / habit-change / stale / paused / no-baseline states.
|
||||
# Data is synthetic but modeled on the real drive (Micron 2400 512GB, ~101 TB
|
||||
# written, 50 % used). Nothing here reads or writes the observation store.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static
|
||||
|
||||
STUB = Path(__file__).with_name("polkit_stub.py")
|
||||
|
||||
# ---------------------------------------------------------------- fake data
|
||||
|
||||
DRIVE = {
|
||||
"model": "Micron_2400_MTFDKBA512QFM",
|
||||
"capacity": "512 GB",
|
||||
"percentage_used": 50,
|
||||
"written_tb": 101.1,
|
||||
"temp": 38,
|
||||
"spare": 100,
|
||||
"media_errors": 0,
|
||||
"power_on_hours": 11126,
|
||||
"power_cycles": 5188,
|
||||
"unsafe_shutdowns": 142,
|
||||
}
|
||||
|
||||
BASELINE = {"tb": 220.0, "source": "Micron 2400 datasheet (PROTOTYPE placeholder provenance)"}
|
||||
|
||||
|
||||
def _years(gb_per_day: float) -> str:
|
||||
days = (BASELINE["tb"] - DRIVE["written_tb"]) * 1000.0 / gb_per_day
|
||||
if days >= 365.25:
|
||||
return f"~{days / 365.25:.1f} years"
|
||||
return f"~{days:.0f} days"
|
||||
|
||||
|
||||
def _days_hist(n: int, rate: float, seed: int) -> list[float]:
|
||||
rng = random.Random(seed)
|
||||
return [max(2.0, rate + rng.gauss(0, rate * 0.18)) for _ in range(n)]
|
||||
|
||||
|
||||
def _sparkline(vals: list[float], width: int = 40) -> str:
|
||||
if not vals:
|
||||
return ""
|
||||
mx = max(vals) or 1.0
|
||||
blocks = " ▁▂▃▄▅▆▇█"
|
||||
step = max(1, len(vals) // width or 1)
|
||||
picked = vals[-width * step:][::step][-width:]
|
||||
return "".join(blocks[min(len(blocks) - 1, int(v / mx * (len(blocks) - 1)) + (1 if v > 0 else 0))] for v in picked)
|
||||
|
||||
|
||||
def _habit_bar(active: float, idle: float, off: float, unknown: float, width: int = 44) -> str:
|
||||
total = active + idle + off + unknown or 1.0
|
||||
segs = [("active", active, "green"), ("idle", idle, "yellow"), ("off", off, "cyan"), ("?", unknown, "magenta")]
|
||||
out = []
|
||||
for label, v, color in segs:
|
||||
n = max(1 if v else 0, round(v / total * width))
|
||||
out.append((f"[{color}]{label[0] * n}[/{color}]"))
|
||||
legend = f" active {active / total:.0%} · idle {idle / total:.0%} · powered-off {off / total:.0%} · unknown {unknown / total:.0%}"
|
||||
return "".join(out) + "\n " + legend
|
||||
|
||||
|
||||
def build_scenarios() -> list[dict]:
|
||||
"""Six states of the ADR-0002 §13 contract + ADR-0003 service facts."""
|
||||
hist_steady = _days_hist(34, 55, seed=7)
|
||||
hist_warm = _days_hist(11, 62, seed=11)
|
||||
hist_changed = _days_hist(35, 70, seed=3) + _days_hist(6, 130, seed=4)
|
||||
hist_stale = _days_hist(34, 55, seed=7)
|
||||
hist_nobase = _days_hist(26, 48, seed=5)
|
||||
hist_paused = _days_hist(28, 51, seed=9)
|
||||
|
||||
def svc(enabled, timer, outcome, freshness, period):
|
||||
return {"enabled": enabled, "timer": timer, "outcome": outcome, "freshness": freshness, "period": period}
|
||||
|
||||
return [
|
||||
{
|
||||
"key": "steady",
|
||||
"name": "steady · Supported",
|
||||
"days": 34,
|
||||
"history": hist_steady,
|
||||
"rate": 55,
|
||||
"headline": _years(55),
|
||||
"confidence": "Supported",
|
||||
"facts": [
|
||||
"34 qualifying days (≥ 14), coverage 92 %",
|
||||
"7- and 28-day rates within a factor of 2",
|
||||
"no single day ≥ 50 % of trailing 28-day writes",
|
||||
"1 day with 3 unknown hours — unexplained gap inside the period",
|
||||
],
|
||||
"horizons": [("last 7 days", _years(48)), ("last 28 days", _years(57))],
|
||||
"habit": (0.34, 0.52, 0.10, 0.04),
|
||||
"gap_days": {-9},
|
||||
"habit_change": None,
|
||||
"service": svc(True, True, "ok · 3 min ago (5 min cadence)", "fresh · newest sample 3 min old", "open since Aug 3 · deliberate disables: 0"),
|
||||
},
|
||||
{
|
||||
"key": "warming",
|
||||
"name": "warming up · Limited",
|
||||
"days": 11,
|
||||
"history": hist_warm,
|
||||
"rate": 62,
|
||||
"headline": _years(62),
|
||||
"confidence": "Limited",
|
||||
"facts": [
|
||||
"warming up: 11 of 14 qualifying days",
|
||||
"coverage 84 %",
|
||||
],
|
||||
"horizons": [("last 7 days", _years(66))],
|
||||
"habit": (0.38, 0.46, 0.12, 0.04),
|
||||
"gap_days": set(),
|
||||
"habit_change": None,
|
||||
"service": svc(True, True, "ok · 2 min ago", "fresh · newest sample 2 min old", "open since Aug 20"),
|
||||
},
|
||||
{
|
||||
"key": "changed",
|
||||
"name": "habit changed · Limited",
|
||||
"days": 41,
|
||||
"history": hist_changed,
|
||||
"rate": 130,
|
||||
"headline": _years(130),
|
||||
"confidence": "Limited",
|
||||
"facts": [
|
||||
"usage habit changed 6 days ago — new regime adopted",
|
||||
"regime 6 days old (young — Limited evidence)",
|
||||
"coverage 88 %",
|
||||
"vendor wear line disagrees ×2.1 with observed write rate",
|
||||
],
|
||||
"horizons": [("last 7 days", _years(128)), ("last 28 days", _years(71))],
|
||||
"habit": (0.47, 0.41, 0.08, 0.04),
|
||||
"gap_days": set(),
|
||||
"habit_change": 6,
|
||||
"service": svc(True, True, "ok · 4 min ago", "fresh · newest sample 4 min old", "open since Jul 15 · habit change noted Aug 26"),
|
||||
},
|
||||
{
|
||||
"key": "stale",
|
||||
"name": "stale · Supported→Limited",
|
||||
"days": 34,
|
||||
"history": hist_stale,
|
||||
"rate": 55,
|
||||
"headline": _years(55),
|
||||
"confidence": "Limited",
|
||||
"facts": [
|
||||
"newest evidence 61 h old (missed — older than 48 h)",
|
||||
"34 qualifying days, coverage 92 %",
|
||||
],
|
||||
"horizons": [("last 7 days", _years(48)), ("last 28 days", _years(57))],
|
||||
"habit": (0.34, 0.52, 0.10, 0.04),
|
||||
"gap_days": {-9},
|
||||
"habit_change": None,
|
||||
"service": svc(True, True, "FAILED · exit 1 · 61 h ago (device busy)", "missed · newest sample 61 h old", "open since Aug 3 · gap is unknown time inside the period"),
|
||||
},
|
||||
{
|
||||
"key": "nobaseline",
|
||||
"name": "no baseline · Unavailable",
|
||||
"days": 26,
|
||||
"history": hist_nobase,
|
||||
"rate": 48,
|
||||
"headline": None,
|
||||
"confidence": "Unavailable",
|
||||
"facts": [
|
||||
"no verified rated-TBW override on record",
|
||||
"vendor wear estimate too coarse to imply endurance (1 of ≥ 2 Percentage Used increments)",
|
||||
],
|
||||
"horizons": [("last 7 days", "48 GB/day (no baseline to project)"), ("last 28 days", "44 GB/day (no baseline to project)")],
|
||||
"habit": (0.31, 0.55, 0.10, 0.04),
|
||||
"gap_days": set(),
|
||||
"habit_change": None,
|
||||
"service": svc(True, True, "ok · 3 min ago", "fresh · newest sample 3 min old", "open since Aug 8"),
|
||||
},
|
||||
{
|
||||
"key": "paused",
|
||||
"name": "paused · Limited",
|
||||
"days": 28,
|
||||
"history": hist_paused,
|
||||
"rate": 51,
|
||||
"headline": _years(51),
|
||||
"confidence": "Limited",
|
||||
"facts": [
|
||||
"no open monitoring period — paused 2 days ago",
|
||||
"paused time is excluded from the usage habit by your choice",
|
||||
],
|
||||
"horizons": [("last 7 days (pre-pause)", _years(53))],
|
||||
"habit": (0.33, 0.51, 0.12, 0.04),
|
||||
"gap_days": set(),
|
||||
"habit_change": None,
|
||||
"service": svc(False, False, "ok · 2 d ago (period closed by pause)", "stale · monitoring paused 2 days ago", "closed 2 days ago · end cause: deliberate disable"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- renderers
|
||||
|
||||
|
||||
def headline_block(sc: dict) -> str:
|
||||
if sc["headline"]:
|
||||
return (
|
||||
f"[bold]Usage-adjusted theoretical lifespan: [white]{sc['headline']}[/white][/bold]\n"
|
||||
f" if current habits continue · sustained regime: {sc['days'] if not sc['habit_change'] else sc['habit_change']} days at {sc['rate']} GB/day"
|
||||
)
|
||||
return "[bold]Usage-adjusted theoretical lifespan: [red]no projection from this history yet[/red][/bold]\n " + "\n ".join(sc["facts"][:2])
|
||||
|
||||
|
||||
def confidence_block(sc: dict) -> str:
|
||||
color = {"Supported": "green", "Limited": "yellow", "Unavailable": "red"}[sc["confidence"]]
|
||||
lines = [f"[bold]Projection confidence: [{color}]{sc['confidence']}[/{color}][/bold]"]
|
||||
lines += [f" · {f}" for f in sc["facts"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def horizon_block(sc: dict) -> str:
|
||||
rows = [f" {label:<28} → [cyan]{value}[/cyan]" for label, value in sc["horizons"]]
|
||||
return "[bold]Scenario range[/bold] (same endurance, other horizons)\n" + "\n".join(rows)
|
||||
|
||||
|
||||
def wear_line(sc: dict) -> str:
|
||||
return f"Vendor wear: {DRIVE['percentage_used']} % used · {DRIVE['written_tb']} TB of {BASELINE['tb']:.0f} TB rated (context, not a second projection)"
|
||||
|
||||
|
||||
def disclosure_lines() -> list[str]:
|
||||
return [
|
||||
"Rated endurance is a vendor guarantee boundary, not a predicted failure date.",
|
||||
"Powered-off time counts toward the projection while monitoring is enabled; deliberately paused time does not.",
|
||||
"The scenario range is a spread of horizons, not a statistical interval.",
|
||||
f"Baseline provenance: {BASELINE['source']}.",
|
||||
]
|
||||
|
||||
|
||||
def history_block(sc: dict, width: int = 60) -> str:
|
||||
vals = sc["history"]
|
||||
spark = _sparkline(vals, width)
|
||||
marks = [" "] * len(spark)
|
||||
if sc["habit_change"]:
|
||||
idx = len(spark) - max(1, round(sc["habit_change"] / max(1, len(vals) // width or 1)))
|
||||
if 0 <= idx < len(marks):
|
||||
marks[idx] = "▲"
|
||||
for g in sc["gap_days"]:
|
||||
idx = len(spark) + g - 1
|
||||
if 0 <= idx < len(marks) and marks[idx] == " ":
|
||||
marks[idx] = "?"
|
||||
head = f"[bold]Usage history[/bold] · {sc['days']} days · {min(vals):.0f}–{max(vals):.0f} GB/day"
|
||||
bar = f"[green]{spark}[/green]"
|
||||
markline = "".join(marks)
|
||||
a, i, o, u = sc["habit"]
|
||||
return head + "\n " + bar + "\n " + markline + " ▲ habit change · ? unexplained gap\n " + _habit_bar(a, i, o, u)
|
||||
|
||||
|
||||
def health_block() -> str:
|
||||
d = DRIVE
|
||||
rows = [
|
||||
f"[bold]Drive health[/bold] · {d['model']}",
|
||||
f" temperature {d['temp']} °C · spare {d['spare']} %",
|
||||
f" media errors {d['media_errors']} · unsafe shutdowns {d['unsafe_shutdowns']}",
|
||||
f" power-on {d['power_on_hours']:,} h · {d['power_cycles']:,} cycles · {d['capacity']}",
|
||||
]
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def service_block(sc: dict) -> str:
|
||||
s = sc["service"]
|
||||
en = "[green]enabled[/green]" if s["enabled"] else "[red]disabled[/red]"
|
||||
tm = "[green]timer active[/green]" if s["timer"] else "[red]timer inactive[/red]"
|
||||
return "\n".join([
|
||||
"[bold]Service[/bold] (four separate facts)",
|
||||
f" boot enablement: {en}",
|
||||
f" runtime activity: {tm}",
|
||||
f" last collect outcome: {s['outcome']}",
|
||||
f" freshness: {s['freshness']}",
|
||||
f" monitoring period: {s['period']}",
|
||||
])
|
||||
|
||||
|
||||
def settings_block() -> str:
|
||||
return "\n".join([
|
||||
"[bold]Settings[/bold] (read view · edit via CLI / drop-ins)",
|
||||
" device: /dev/disk/by-id/nvme-Micron_2400_MTFDKBA512QFM_2341ABCD",
|
||||
" cadence: every 5 min (systemd drop-in to change) · raw retention 14 d",
|
||||
f" endurance baseline: {BASELINE['tb']:.0f} TB rated — {BASELINE['source']}",
|
||||
])
|
||||
|
||||
|
||||
def actions_legend(paused: bool) -> str:
|
||||
resume = "[bold green]r resume[/bold green]" if paused else "r resume"
|
||||
pause = "[bold yellow]p pause[/bold yellow]" if not paused else "p pause"
|
||||
return f"{pause} (asks) · {resume} · c collect now · s state · ←/→ variant · d disclosures · q quit"
|
||||
|
||||
|
||||
VARIANTS = [
|
||||
("A", "Panes — one dense screen"),
|
||||
("B", "Pages — persistent header + tabbed body"),
|
||||
("C", "Ledger — scrolling narrative document"),
|
||||
]
|
||||
|
||||
# ----------------------------------------------------------------- screens
|
||||
|
||||
|
||||
class ConfirmPause(ModalScreen[bool]):
|
||||
"""Pause asks for confirmation (ADR 0003 §8)."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("y", "yes", "Pause"),
|
||||
Binding("n", "no", "Cancel"),
|
||||
Binding("escape", "no", "Cancel", show=False),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
"[bold]Pause monitoring?[/bold]\n\n"
|
||||
"This closes the current monitoring period.\n"
|
||||
"Paused time is [bold]excluded[/bold] from your usage habit\n"
|
||||
"(powered-off time would still count).\n\n"
|
||||
"[dim]y pause · n cancel[/dim]",
|
||||
id="confirm-text",
|
||||
)
|
||||
|
||||
def action_yes(self) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
def action_no(self) -> None:
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
class Disclosures(ModalScreen):
|
||||
BINDINGS = [Binding("escape", "close", "Close"), Binding("d", "close", "Close")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield VerticalScroll(Static("\n".join(["[bold]Disclosures[/bold]"] + [f" · {d}" for d in disclosure_lines()]) + "\n\n[dim]esc to close[/dim]", id="disc-text"), id="disc-wrap")
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- app
|
||||
|
||||
|
||||
class FenrisPrototypeApp(App):
|
||||
TITLE = "Fenris — PROTOTYPE (throwaway)"
|
||||
SUB_TITLE = "TUI information architecture · ticket #3"
|
||||
|
||||
BINDINGS = [
|
||||
Binding("left", "prev_variant", "‹ variant", show=False),
|
||||
Binding("right", "next_variant", "variant ›", show=False),
|
||||
Binding("s", "cycle_state", "state", show=False),
|
||||
Binding("p", "pause", "pause", show=False),
|
||||
Binding("r", "resume", "resume", show=False),
|
||||
Binding("c", "collect", "collect now", show=False),
|
||||
Binding("d", "disclose", "disclosures", show=False),
|
||||
Binding("q", "quit", "quit", show=False),
|
||||
Binding("1", "page('overview')", "overview", show=False),
|
||||
Binding("2", "page('history')", "history", show=False),
|
||||
Binding("3", "page('drive')", "drive", show=False),
|
||||
Binding("4", "page('service')", "service", show=False),
|
||||
Binding("5", "page('settings')", "settings", show=False),
|
||||
Binding("j", "scroll_down", "scroll down", show=False),
|
||||
Binding("k", "scroll_up", "scroll up", show=False),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
#vhost { height: 1fr; }
|
||||
#vA { layout: grid; grid-size: 2 3; grid-columns: 3fr 2fr; grid-rows: 8 1fr 7; height: 1fr; }
|
||||
#vA-head, #vA-bottom { column-span: 2; }
|
||||
#vA-history { overflow-y: auto; }
|
||||
.pane { border: round #555555; padding: 0 1; }
|
||||
#vB-head { height: 6; border-bottom: thick #555555; padding: 0 1; }
|
||||
#vB-body { height: 1fr; padding: 0 1; }
|
||||
#vB-legend { height: 3; }
|
||||
.page { height: 1fr; padding: 0 1; }
|
||||
#vC { height: 1fr; padding: 0 2; }
|
||||
#switchbar { height: 3; dock: bottom; background: $boost; }
|
||||
#sb-variant { width: 1fr; text-style: reverse; }
|
||||
#sb-state { width: 1fr; }
|
||||
#sb-actions { width: 2fr; }
|
||||
#confirm-text { padding: 1 2; }
|
||||
#disc-wrap { padding: 1 2; height: auto; max-height: 80%; }
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.scenarios = build_scenarios()
|
||||
self.scenario_idx = 0
|
||||
self.variant_idx = 0
|
||||
self.b_page = "overview"
|
||||
self.tty_log: list[str] = []
|
||||
|
||||
# ---- composition
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="vhost"):
|
||||
with Vertical(id="vA"):
|
||||
yield Static("", id="vA-head", classes="pane")
|
||||
yield Static("", id="vA-history", classes="pane")
|
||||
yield Static("", id="vA-health", classes="pane")
|
||||
yield Static("", id="vA-bottom", classes="pane")
|
||||
with Vertical(id="vB"):
|
||||
yield Static("", id="vB-head")
|
||||
with Vertical(id="vB-body"):
|
||||
yield Static("", id="vB-overview", classes="page")
|
||||
yield Static("", id="vB-history", classes="page")
|
||||
yield Static("", id="vB-drive", classes="page")
|
||||
yield Static("", id="vB-service", classes="page")
|
||||
yield Static("", id="vB-settings", classes="page")
|
||||
yield Static("", id="vB-legend")
|
||||
with VerticalScroll(id="vC"):
|
||||
yield Static("", id="vC-doc")
|
||||
with Horizontal(id="switchbar"):
|
||||
yield Static("", id="sb-variant")
|
||||
yield Static("", id="sb-state")
|
||||
yield Static("", id="sb-actions")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
for vid in ("vA-head", "vA-history", "vA-health", "vA-bottom"):
|
||||
w = self.query_one(f"#{vid}", Static)
|
||||
w.border_title = {"vA-head": "headline", "vA-history": "usage history", "vA-health": "drive", "vA-bottom": "service + actions"}[vid]
|
||||
self.render_all()
|
||||
|
||||
# ---- helpers
|
||||
|
||||
@property
|
||||
def sc(self) -> dict:
|
||||
return self.scenarios[self.scenario_idx]
|
||||
|
||||
def w(self, vid: str) -> Static:
|
||||
return self.query_one(f"#{vid}", Static)
|
||||
|
||||
def render_all(self) -> None:
|
||||
sc = self.sc
|
||||
paused = not sc["service"]["enabled"]
|
||||
key, name = VARIANTS[self.variant_idx]
|
||||
|
||||
# variant A: everything on one dense screen
|
||||
self.w("vA-head").update(headline_block(sc) + "\n" + confidence_block(sc))
|
||||
self.w("vA-history").update(history_block(sc) + "\n" + horizon_block(sc))
|
||||
self.w("vA-health").update(health_block() + "\n\n" + settings_block())
|
||||
self.w("vA-bottom").update(service_block(sc) + "\n " + actions_legend(paused))
|
||||
|
||||
# variant B: persistent header, tabbed pages
|
||||
head = "\n".join([
|
||||
headline_block(sc),
|
||||
confidence_block(sc).split("\n")[0] + f" · {sc['confidence']}",
|
||||
f"freshness: {sc['service']['freshness']} · wear: {DRIVE['percentage_used']} %",
|
||||
])
|
||||
self.w("vB-head").update(head)
|
||||
self.w("vB-overview").update(confidence_block(sc) + "\n\n" + horizon_block(sc) + "\n\n" + wear_line(sc))
|
||||
self.w("vB-history").update(history_block(sc, 70))
|
||||
self.w("vB-drive").update(health_block() + "\n\n" + wear_line(sc))
|
||||
self.w("vB-service").update(service_block(sc) + "\n\n " + actions_legend(paused) + "\n\n tty log:\n" + ("\n".join(self.tty_log) if self.tty_log else " (no privileged action taken yet)"))
|
||||
self.w("vB-settings").update(settings_block() + "\n\n" + "\n".join(" · " + d for d in disclosure_lines()))
|
||||
self.w("vB-legend").update(f"pages: 1 overview · 2 history · 3 drive · 4 service · 5 settings [now: {self.b_page}]")
|
||||
for p in ("overview", "history", "drive", "service", "settings"):
|
||||
self.w(f"vB-{p}").styles.display = "block" if p == self.b_page else "none"
|
||||
|
||||
# variant C: one scrolling document in reading order
|
||||
doc = "\n\n".join([
|
||||
"[dim]═" * 70 + "[/dim]",
|
||||
headline_block(sc),
|
||||
confidence_block(sc),
|
||||
horizon_block(sc),
|
||||
wear_line(sc),
|
||||
history_block(sc, 70),
|
||||
health_block(),
|
||||
service_block(sc),
|
||||
settings_block(),
|
||||
"[bold]Disclosures[/bold]\n" + "\n".join(" · " + d for d in disclosure_lines()),
|
||||
"[dim]═" * 70 + "[/dim]",
|
||||
])
|
||||
self.w("vC-doc").update(doc)
|
||||
|
||||
# variant visibility + switcher
|
||||
for i, vid in enumerate(("vA", "vB", "vC")):
|
||||
self.query_one(f"#{vid}").styles.display = "block" if i == self.variant_idx else "none"
|
||||
self.w("sb-variant").update(f" ← {key} · {name} → ")
|
||||
self.w("sb-state").update(f" state [{self.scenario_idx + 1}/{len(self.scenarios)}]: {sc['name']} (s to cycle) ")
|
||||
self.w("sb-actions").update(" " + actions_legend(paused))
|
||||
|
||||
# ---- actions
|
||||
|
||||
def action_prev_variant(self) -> None:
|
||||
self.variant_idx = (self.variant_idx - 1) % len(VARIANTS)
|
||||
self.render_all()
|
||||
|
||||
def action_next_variant(self) -> None:
|
||||
self.variant_idx = (self.variant_idx + 1) % len(VARIANTS)
|
||||
self.render_all()
|
||||
|
||||
def action_cycle_state(self) -> None:
|
||||
self.scenario_idx = (self.scenario_idx + 1) % len(self.scenarios)
|
||||
self.render_all()
|
||||
|
||||
def action_page(self, page: str) -> None:
|
||||
if VARIANTS[self.variant_idx][0] != "B":
|
||||
return
|
||||
self.b_page = page
|
||||
self.render_all()
|
||||
|
||||
def action_scroll_down(self) -> None:
|
||||
if VARIANTS[self.variant_idx][0] == "C":
|
||||
self.query_one("#vC").scroll_down(animated=False)
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
if VARIANTS[self.variant_idx][0] == "C":
|
||||
self.query_one("#vC").scroll_up(animated=False)
|
||||
|
||||
def action_disclose(self) -> None:
|
||||
self.push_screen(Disclosures())
|
||||
|
||||
def action_pause(self) -> None:
|
||||
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
|
||||
|
||||
def _pause_confirmed(self, confirmed: bool) -> None:
|
||||
if not confirmed:
|
||||
self.tty_log.append("pause: cancelled at confirmation")
|
||||
self.render_all()
|
||||
return
|
||||
result = self._run_tty_stub("pause (disable --now)")
|
||||
if "OK" in result:
|
||||
self.scenarios[self.scenario_idx]["service"] = {
|
||||
"enabled": False, "timer": False,
|
||||
"outcome": "ok · period closed by pause",
|
||||
"freshness": "paused · no collection while disabled",
|
||||
"period": "closed just now · end cause: deliberate disable",
|
||||
}
|
||||
self.tty_log.append(result)
|
||||
self.render_all()
|
||||
|
||||
def action_resume(self) -> None:
|
||||
result = self._run_tty_stub("resume (enable --now)") # no confirmation (ADR 0003 §8)
|
||||
if "OK" in result or "skipped" in result:
|
||||
self.scenarios[self.scenario_idx]["service"] = {
|
||||
"enabled": True, "timer": True,
|
||||
"outcome": "ok · resumed just now",
|
||||
"freshness": "fresh · collection resuming",
|
||||
"period": "open just now",
|
||||
}
|
||||
self.tty_log.append(result)
|
||||
self.render_all()
|
||||
|
||||
def action_collect(self) -> None:
|
||||
result = self._run_tty_stub("collect now", blocking=True) # synchronous outcome (ADR 0003 §7)
|
||||
self.tty_log.append(result)
|
||||
self.render_all()
|
||||
|
||||
# ---- tty passthrough validation (the point of the stub)
|
||||
|
||||
def _run_tty_stub(self, op: str, blocking: bool = False) -> str:
|
||||
if os.environ.get("FENRIS_PROTOTYPE_NO_TTY") or not sys.stdin.isatty():
|
||||
return f"{op}: tty stub SKIPPED (headless run — mechanism not exercised)"
|
||||
try:
|
||||
with self.suspend():
|
||||
proc = subprocess.run([sys.executable, str(STUB), op])
|
||||
verdict = "OK — suspend + terminal passthrough works" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
|
||||
return f"{op}: {verdict}"
|
||||
except Exception as exc: # SuspendNotSupported and friends
|
||||
return f"{op}: suspend failed: {type(exc).__name__} — this terminal may not support passthrough"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = FenrisPrototypeApp()
|
||||
print("[fenris prototype] throwaway UI for ticket #3 — variants switch with ←/→, states with s\n")
|
||||
app.run()
|
||||