Compare commits

..
Author SHA1 Message Date
xavierk a0e4690703 prototype(tui): throwaway TUI information-architecture prototype (ticket #3)
Three structurally different variants (Panes / Pages / Ledger), switchable
live, plus a six-state scenario rotator (steady, warming up, habit changed,
stale, no baseline, paused) driving the ADR-0002 section-13 contract and the
four separate ADR-0003 section-8 service facts. Pause/resume/collect suspend
the TUI and run a polkit stand-in on the real terminal to validate tty
passthrough. Headless smoke test + SVG screenshots included.
2026-08-31 16:30:28 +05:30
32 changed files with 3821 additions and 198 deletions
-1
View File
@@ -1,5 +1,4 @@
__pycache__/
.pi/
*.pyc
.commandcode/
data/fenris.pid
-9
View File
@@ -1,9 +0,0 @@
## Agent skills
### Issue tracker
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
### Domain docs
This is a single-context repository. See `docs/agents/domain.md`.
+1 -13
View File
@@ -28,10 +28,6 @@ _Avoid_: Daemon uptime, calibration window
The single SQLite database at `/var/lib/fenris/observations.db` that persists the observation history, monitoring periods, hour observations, day aggregates, and endurance baseline.
_Avoid_: Data directory, history.jsonl, the database (generic)
**Store fault**:
The condition where the observation store is present but cannot be read or trusted — unreadable, corrupt, or written by a newer Fenris — degrading every view that depends on it rather than crashing or guessing.
_Avoid_: Database error, corruption, broken data
**Hour observation**:
One row per UTC hour in the observation store, recording that hour's usage-habit split into active, idle, powered-off, and unknown seconds, plus write/read deltas, thermal evidence, and coverage.
_Avoid_: Hourly record, hourly.jsonl entry
@@ -45,17 +41,9 @@ A span of observation history within which the drive's controller identity is un
_Avoid_: Counter reset handling, drive swap detection
**Endurance baseline**:
The write-endurance value a projection consumes, chosen by precedence: a verified override when one exists, otherwise an unverified override, otherwise a coarse implied baseline derived from vendor wear — each labeled as such.
The write-endurance value a projection consumes: a verified rated-TBW override stored with provenance when one exists, otherwise a coarse implied baseline derived from vendor wear and labeled as such.
_Avoid_: TBW value, failure threshold, max writes
**Verified override**:
A rated-TBW override with complete provenance whose applicability to the detected drive was confirmed by machine match or explicit user attestation; the strongest endurance baseline.
_Avoid_: Confirmed TBW, trusted value
**Unverified override**:
A rated-TBW override knowingly stored with incomplete provenance; always presented as user-supplied, never as verified.
_Avoid_: Forced entry, fallback baseline
**Sustained regime**:
The most recent stretch of the observation history over which the observed usage habit has been stable; the interval whose write rate the usage-adjusted theoretical lifespan consumes.
_Avoid_: Current window, detection period
+1 -3
View File
@@ -4,8 +4,6 @@
Accepted — resolves [Define the persistent observation store and legacy migration](https://git.bongbetic.com/xavierk/Fenris/issues/2) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
Amended by [Define endurance-baseline provenance and validation](https://git.bongbetic.com/xavierk/Fenris/issues/12): the `endurance_baseline` field set and validation contract — derived verification, entry-time unprivileged sysfs validation, and read-time controller-segment applicability.
## Context
Fenris today persists full SMART samples to an append-only `data/history.jsonl` beside a derived `data/hourly.jsonl`, both in the checkout, with no schema versioning and silent skipping of malformed lines. The redesign replaces the HTML dashboard with a keyboard-first TUI backed by a short-lived privileged collector on a systemd timer and an unprivileged TUI ([lifecycle research](https://git.bongbetic.com/xavierk/Fenris/src/branch/research/systemd-privilege-lifecycle/docs/research/systemd-privilege-lifecycle.md)), and projects a usage-adjusted theoretical lifespan from Data Units Written over wall-clock time with categorical confidence ([endurance research](https://git.bongbetic.com/xavierk/Fenris/src/branch/research/nvme-endurance-signals/docs/research/nvme-endurance-signals.md)). The store must support a root writer appearing every few minutes while an unprivileged reader queries concurrently, must migrate the legacy observation history idempotently and interruption-safely, and must version its schema.
@@ -20,7 +18,7 @@ Fenris today persists full SMART samples to an append-only `data/history.jsonl`
- `day_aggregates` — one row per UTC day; the habit-evidence grain.
- `monitoring_periods` — `started_at`, `ended_at` (NULL = open), `end_cause` enum (`user_disabled`, `migrated`, …). Powered-off time stays inside a period; deliberately disabled time does not.
- `controller_segments` — boundaries where controller identity changes or DUW decreases; write deltas are never computed across a segment.
- `endurance_baseline` — one active row, replaced on edit ([Define endurance-baseline provenance and validation](https://git.bongbetic.com/xavierk/Fenris/issues/12)): the rated-TBW value in bytes (`E_rated = entered_TBW × 10¹²`) plus mandatory provenance — source URL, document revision, entry date, model string, nominal capacity — and frozen validation facts (detected model, detected capacity bytes, `validated_by` `machine`/`user`, `validated_at`). Verification is derived at read — complete provenance and a drive match (machine or attested), never a stored boolean; incomplete provenance stores only behind an explicit unverified acknowledgment, as NULL fields in that precedence tier. Entry validation is an unprivileged live sysfs read of the configured device (normalized model containment with an interactive confirm recorded as `validated_by = user`; capacity within ±1%); at projection time applicability is a model match against the current controller segment, and a mismatch is retained — never auto-deleted — leaving the projection Unavailable.
- `endurance_baseline` — verified rated-TBW override in bytes plus provenance (source URL, document revision, entry date).
- Projections are not stored; they are recomputed on read. There is no separate latest-status table.
4. **Day boundary**: UTC, matching hours, so day derivation from hour rows is monotonic and DST-ambiguous or 23/25-hour days never exist in the store.
5. **Retention**: raw samples are kept 14 days and pruned opportunistically by the collector; hour observations and day aggregates are retained indefinitely.
@@ -4,8 +4,6 @@
Accepted — resolves [Define the collector, service, and CLI lifecycle](https://git.bongbetic.com/xavierk/Fenris/issues/8) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1). Amends the toggle mechanism of [Verify systemd lifecycle and privilege constraints](https://git.bongbetic.com/xavierk/Fenris/issues/7); its spirit — scoped, explicit, authenticated, no generic `manage-unit-files` grant — is intact.
Amended by [Define endurance-baseline provenance and validation](https://git.bongbetic.com/xavierk/Fenris/issues/12): the helper gains a `baseline` verb that persists the CLI-validated endurance-baseline row — same fixed-operation, polkit-mediated pattern.
## Context
Fenris's current single process combines daemonization, a PID file, an HTTP dashboard, and control (`fenris.py start/stop/status/sample`) over checkout-relative state. [ADR 0001](0001-observation-store-sqlite.md) fixed the observation store, including `monitoring_periods` whose `user_disabled` end cause records deliberate pauses, and the [systemd lifecycle research](https://git.bongbetic.com/xavierk/Fenris/src/branch/research/systemd-privilege-lifecycle/docs/research/systemd-privilege-lifecycle.md) fixed the timer + oneshot architecture, standard paths, journal diagnostics, allow-listed status reads, and polkit-mediated startup toggles — while leaving cadence mechanics, the configuration surface, CLI compatibility, staleness thresholds, and the mechanism that records a deliberate disable open. In particular, `systemctl enable`/`disable` cannot write a monitoring-period row, so a direct-systemctl toggle cannot satisfy the store's semantics.
@@ -15,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
2. **Cadence.** Default five minutes: `OnBootSec=2min`, `OnUnitInactiveSec=5min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger and monitoring-period bookkeeping; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
6. **Period rows.** Idempotent matrix: a first-ever enable opens a period at the enable moment (hours before the first successful sample are unknown-but-inside, correctly so when the device errors); a resume with an open period — a raw `systemctl stop` intervened — changes no row, the gap remaining inside as unknown seconds; a resume with no open period opens a new row at the resume moment; a pause with an open period closes it `user_disabled` at the pause moment; a pause otherwise is a no-op. A raw stop or disable outside the helper is an unexplained gap, never `user_disabled`: only the sanctioned path can record intent.
7. **On-demand collection.** `fenris sample` and the TUI's collect-now route through `fenris-monitor` → `systemctl start fenris-collect.service`, which blocks until the oneshot exits, and the outcome (freshness line or journal hint) is reported synchronously. No code path outside `fenris-collect` touches the device; the TUI never samples in-process; no confirmation is required.
@@ -1,31 +0,0 @@
# 4. Installation lifecycle: Makefile-delivered venv, dormant install, sanctioned teardown
## Status
Accepted — resolves [Define installation, upgrade, and removal behavior](https://git.bongbetic.com/xavierk/Fenris/issues/9) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
## Context
Fenris runs today from the source checkout (`fenris.py`, `fenris.sh`, `data/`): code, state, and control all live relative to wherever the checkout sits. The redesign fixes system artifacts — helpers in `/usr/libexec/fenris` ([ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)), the observation store at `/var/lib/fenris/observations.db` ([ADR 0001](0001-observation-store-sqlite.md)), configuration at `/etc/fenris/fenris.conf` ([ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)), Textual on Python 3.9+ ([framework decision](https://git.bongbetic.com/xavierk/Fenris/issues/6)) — but nothing says how those artifacts are delivered, upgraded, or removed, or what happens when the checkout moves or disappears.
## Decision
1. **Delivery.** `sudo make install` builds a wheel from the checkout and installs it, with pinned dependencies, into a dedicated Fenris-owned venv at `/opt/fenris`; a `/usr/local/bin/fenris` wrapper makes the unprivileged TUI/CLI a PATH command. The checkout is build-time input only: after install, nothing references it.
2. **Layout and manifest.** Units in `/etc/systemd/system` (`fenris-collect.{timer,service}`, [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)); helpers in `/usr/libexec/fenris`; polkit policy in `/usr/share/polkit-1/actions/`; configuration and observation store in their ADR-fixed locations. The installer records every file it places in an explicit manifest consumed by upgrade and uninstall.
3. **Privilege.** One root installer (`sudo make install`); at runtime, elevation is exclusively polkit (`auth_admin`, `fenris-monitor` only, [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)). The installer never enables or starts units.
4. **Dormant install.** A fresh install is fully dormant: units present but disabled, nothing running, no monitoring period. The only opt-in is the sanctioned toggle (`fenris monitor resume [--now]`, or the first-run TUI prompt), which enables the timer and opens the first period in one step.
5. **Legacy import.** The installer detects `./data/history.jsonl` beside the source (or accepts an explicit path), runs [ADR 0001](0001-observation-store-sqlite.md)'s idempotent single-transaction import, and reports imported counts — existing observations never depend on checkout survival. `fenris import <path>` remains available for later finds.
6. **Upgrade.** `sudo make upgrade` builds and installs the new wheel into the same venv, syncs units and polkit against the manifest (`daemon-reload`; restart the timer only if unit contents changed and it is active — safe with `Persistent=no`), leaves timer state untouched, and never kills an in-flight collection run: a running oneshot finishes on its mapped interpreter, so at worst one old-code run completes to the store and the next run uses the new code. It then applies forward-only observation-store schema migrations governed by a `schema_version` table. `/var/lib/fenris` is never rebuilt.
7. **Rollback.** Best-effort by design: before migrations run, the installer snapshots `observations.db` to a one-generation `observations.db.bak`; rollback means reinstalling the previous version and restoring the backup. Automatic schema downgrade is explicitly unsupported.
8. **Removal.** `make uninstall` first performs the sanctioned disable (`fenris-monitor disable --now`) so an open monitoring period closes `user_disabled` — removal is deliberate, and only the sanctioned path records intent — then stops and disables the units and removes the venv, helpers, units, polkit policy, and wrapper, **keeping** `/etc/fenris` and the observation store. `make purge` additionally removes configuration and store. Journal entries age out naturally.
9. **Dependencies.** Exact pins in a committed lockfile; install and upgrade both install from it. Refreshing pins is an explicit developer step (`make update-deps`, committed), never a side effect of installing.
10. **Scaffolding and floor.** The installer creates `/var/lib/fenris` with [ADR 0001](0001-observation-store-sqlite.md)'s root-written group-read permissions and verifies `python3 ≥ 3.9`, failing cleanly otherwise — the Textual contingency becomes an install-time gate rather than a runtime crash. The database file itself is created lazily by the first write, so "no observations yet" remains a real state the TUI can greet.
## Consequences
- Installed Fenris survives checkout deletion; the checkout is only where builds happen.
- Teardown preserves monitoring-period semantics: deliberate removal excludes the uninstalled span from the usage habit instead of leaving it as unknown-inside.
- Installs are reproducible; dependency drift cannot ride in on an upgrade.
- Reinstall after uninstall resumes from the preserved observation store; only purge erases history.
- Rollback support is exactly one generation deep, no further.
- The README documents install, upgrade, uninstall/purge, and legacy import alongside [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)'s menu-successor mapping.
@@ -1,27 +0,0 @@
# 5. Failure and recovery: visible degradation, never fabrication
## Status
Accepted — resolves [Define failure and recovery behavior](https://git.bongbetic.com/xavierk/Fenris/issues/10) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
## Context
The observation store ([ADR 0001](0001-observation-store-sqlite.md)) and the service lifecycle ([ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)) settled single-writer transactions, bounded single failed runs, freshness grading (fresh / missed / stale), and absent-hour classification through power-on-hours evidence. Left open by the [failure ticket](https://git.bongbetic.com/xavierk/Fenris/issues/10): behavior per failure class — malformed observations inside the store, missed observations, store faults (unreadable, corrupt, or newer-schema database), and repeated collector failures — and how the habit record re-anchors after the store itself is lost.
## Decision
1. **Malformed observations — refuse at the write boundary.** The collector validates every row it would write against the store's domain invariants (hour seconds sum to 3600, non-negative DUW delta within a controller segment, coverage consistent with sample count). A violating run writes nothing for that run, logs the refused row to the journal for post-mortem, and fails visibly — retried next interval. Readers (TUI, `status`) defensively exclude and count malformed rows as a contributing fact, but under a single trusted writer they should never see one. Store invariant: everything persisted is well-formed.
2. **Missed observations — never backfill.** Fenris never interpolates, estimates, or fabricates an hour. Gaps remain unknown seconds; degradation flows exclusively through coverage, freshness facts, and confidence categories; recovery is the timer's next successful run. [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)'s power-on-hours classification is the only inference admitted.
3. **Store faults — degrade, never recreate over.** An unreadable or corrupt database is a store fault: readers surface a "observation store unreadable" fact with the journal hint and show nothing else that depends on the store; the collector treats it as a bounded failed run and never recreates or overwrites an existing file. Recovery is human-sanctioned and documented: back up or move the corrupt file aside, the next run starts a fresh store, and if the legacy import never completed, the still-present `history.jsonl` is re-imported. No built-in destructive command exists.
4. **Newer schema — readers refuse symmetrically.** The TUI and `status` detect a `user_version` newer than they understand and display "observation store written by a newer Fenris — upgrade Fenris" without partial interpretation, matching the collector's refusal in [ADR 0001](0001-observation-store-sqlite.md) and the forward-only upgrade rule of [ADR 0004](0004-install-upgrade-removal-lifecycle.md).
5. **Repeated collector failures — flat cadence, no escalation.** The timer's retry is the recovery path; the settled freshness grading walks fresh → missed → stale as failures persist, so degradation is visible without new state. No backoff and no notification machinery; a persistent failure reads as stale exactly like any other gap.
6. **Drive-reported anomalies — facts, not alerts.** `critical_warning`, media errors, and unsafe shutdowns surface as ordinary facts in the TUI and `status`; no alerting or notification surface exists. Fenris observes and projects; it does not alarm. The projection is unaffected: endurance math consumes writes, not warnings.
7. **Orphaned samples — the collector re-anchors observed fact.** When a collection run finds no open monitoring period (fresh store after a store fault, completed legacy re-import, or first-ever run), it opens one at the run moment, never backdated. This records observed fact, not intent: only the sanctioned path of [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) records a `user_disabled` close. Coverage semantics stay intact without requiring a re-run of `fenris-monitor enable` after recovery.
## Consequences
- Validation lives at one boundary — the collector — so the store's contract is "everything in it is well-formed" and readers only defend against the impossible.
- No synthetic data can ever enter the habit record; confidence categories can be trusted to reflect real evidence.
- Store-fault recovery can lose history; the mitigation is the one-file backup story of [ADR 0001](0001-observation-store-sqlite.md), kept human-sanctioned so loss is never silent.
- Period bookkeeping splits by epistemics: the helper records intent, the collector records observed fact.
- Fenris stays fully local and silent: no notification, escalation, or alerting machinery anywhere.
-26
View File
@@ -1,26 +0,0 @@
# Domain Docs
How engineering skills should consume this repository’s domain documentation.
## Layout
This is a single-context repository:
```text
/
├── CONTEXT.md
├── docs/adr/
└── ...
```
## Before exploring
Read `CONTEXT.md` and relevant ADRs under `docs/adr/` when they exist. If they do not exist, proceed silently. Domain-modeling skills create them lazily when terminology or durable architectural decisions are resolved.
## Use the glossary’s vocabulary
Use terminology defined in `CONTEXT.md` consistently. If required terminology is missing or contradictory, raise it through domain modeling rather than silently inventing synonyms.
## Flag ADR conflicts
If proposed work contradicts an existing ADR, identify the conflict explicitly instead of silently overriding it.
-85
View File
@@ -1,85 +0,0 @@
# Issue tracker: Gitea
Issues for this repository live in Gitea at:
https://git.bongbetic.com/xavierk/Fenris/issues
Use the authenticated `tea` CLI from the repository root. The configured login is `xavierk`.
## General operations
- List: `tea issues list`
- Read: `tea issues <index> --comments`
- Create: `tea issues create --title "<title>" --description "<body>"`
- Edit: `tea issues edit <index> --title "<title>" --description "<body>"`
- Assign: `tea issues edit <index> --add-assignees "<username>"`
- Add labels: `tea issues edit <index> --add-labels "<labels>"`
- Comment: `tea comments add <index> --description "<comment>"`
- Close: `tea issues close <index>`
- Reopen: `tea issues reopen <index>`
Use `--output json` for machine-readable list and read operations. Use `tea api` when the high-level issue commands do not expose a native Gitea operation.
## When a skill says “publish to the issue tracker”
Create a Gitea issue in this repository. Preserve Markdown formatting in its body and apply any labels required by the invoking skill.
## When a skill says “fetch the relevant ticket”
Read the named issue with comments. The user may provide its URL, title, or index. In user-facing output, refer to issues by their linked titles rather than bare indices.
## Wayfinding operations
Wayfinder maps and decision tickets are Gitea issues.
### Map and ticket grouping
- A map has the label `wayfinder:map`.
- Create one milestone named `Wayfinder: <map title>` for the effort.
- Assign the map and all its tickets to that milestone.
- Every ticket links its parent by name near the top: `Parent map: [<map title>](<map URL>)`.
- Every ticket has exactly one type label: `wayfinder:research`, `wayfinder:prototype`, `wayfinder:grilling`, or `wayfinder:task`.
The shared milestone and explicit parent link express the child relationship, because this Gitea version has no native parent/child issue API.
### Blocking
Use Gitea’s native issue-dependency relationship. To make `<blocked>` depend on `<blocker>`:
```bash
tea api -X POST \
repos/{owner}/{repo}/issues/<blocked>/dependencies \
-F index=<blocker> \
-f owner=xavierk \
-f repo=Fenris
```
List blockers:
```bash
tea api repos/{owner}/{repo}/issues/<index>/dependencies
```
Remove the relationship with the same payload and `-X DELETE`.
### Frontier
List open issues in the map’s milestone. Exclude:
- the issue labelled `wayfinder:map`
- assigned tickets, because assignment is the claim
- tickets whose dependency query returns any open issue
The remaining open, unassigned, unblocked tickets are the frontier. Choose the oldest first unless the user names one.
### Claim
Before doing any ticket work, assign it to the current `tea whoami` user. An open ticket without an assignee is unclaimed.
### Resolve
1. Add the answer as a resolution comment.
2. Close the ticket.
3. Re-fetch the map immediately before editing it.
4. Append a linked one-line context pointer to `Decisions so far`.
5. Create newly visible tickets, then wire dependencies in a second pass.
+2
View File
@@ -0,0 +1,2 @@
.venv/
__pycache__/
+46
View File
@@ -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.
+29
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
textual>=0.60
+14
View File
@@ -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
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 61 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

+67
View File
@@ -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())
+573
View File
@@ -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()