diff --git a/.github/changelog.sh b/.github/changelog.sh new file mode 100755 index 0000000..b510e0b --- /dev/null +++ b/.github/changelog.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Build a changelog for one release, from the commit subjects between two +# tags. +# +# The subjects here are prose, not conventional commits - "netwatch: every +# rate read a quarter too high" rather than "fix(netwatch): ...". There is no +# type to group by and inventing one would mean rewriting three hundred +# subjects. What every subject does carry is the part before the first colon, +# which is the widget or the area it touched, so that is the grouping: a +# reader asking "what changed in ports" gets an answer, which is the question +# a changelog for fourteen separate binaries is actually asked. +# +# A prefix only counts as a scope if it names something real - a widget in +# widgets/src/bin, or one of the areas below. Early history has subjects like +# "Add agents.py: every coding agent", where the part before the colon is the +# start of a sentence rather than a scope; taken at face value those made +# forty-seven sections out of three hundred commits. The widget list is read +# from the tree rather than written down here, so a new widget is a scope the +# day it lands. +# +# Usage: changelog.sh [previous-tag] +# With no previous tag it takes the whole history, which is what the first +# release needs and what it will get, there being no tags yet. +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tag="${1:?usage: changelog.sh [previous-tag]}" +prev="${2:-}" + +if [ -z "$prev" ]; then + prev=$(git describe --tags --abbrev=0 "${tag}^" 2>/dev/null || true) +fi +if [ -n "$prev" ]; then + range="${prev}..${tag}" + since="since ${prev}" +else + range="$tag" + since="everything so far" +fi + +# Every widget, plus the places that are not one. +scopes=$( + { ls "$here/widgets/src/bin" 2>/dev/null | sed -n 's/\.rs$//p' + printf '%s\n' core widgets docs tests ci rust release + } | sort -u +) + +printf '## %s\n\n' "$tag" +printf '%s commits, %s.\n\n' "$(git log --oneline "$range" | wc -l | tr -d ' ')" "$since" + +git log --format='%s' "$range" | awk -v known="$scopes" -F': ' ' + BEGIN { n = split(known, k, "\n"); for (i = 1; i <= n; i++) if (k[i] != "") is[k[i]] = 1 } + { + if (NF < 2) { print "everything else\t" $0; next } + scope = $1 + rest = substr($0, length(scope) + 3) + n = split(scope, parts, /, */) + # Every part has to be a real scope. "linear, netwatch" is two + # widgets; "Add agents.py" is a sentence that happens to contain a + # comma-free prefix and belongs whole, under everything else. + good = (n > 0) + for (i = 1; i <= n; i++) if (!(parts[i] in is)) good = 0 + if (!good) { print "everything else\t" $0; next } + for (i = 1; i <= n; i++) print parts[i] "\t" rest + } +' | sort -f -s -t"$(printf '\t')" -k1,1 | awk -F'\t' ' + $1 != seen { if (seen != "") printf "\n"; printf "### %s\n\n", $1; seen = $1 } + { printf "- %s\n", $2 } +' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..64bdd64 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,87 @@ +# CodeQL, in advanced setup rather than default. +# +# Default setup cannot do this. Its API accepts only actions, c-cpp, csharp, +# go, java-kotlin, javascript-typescript, python, ruby and swift - `rust` is +# refused with a 422 - so the repository was still scanning `python` months +# after the last .py file was deleted, which is to say scanning nothing. +# +# Rust support is public preview, from CodeQL 2.22.1. It needs no build step: +# the extractor reads the sources, so there is no `autobuild` here and no +# `build-mode`, which is how github/codeql-action's own Rust check runs. +# +# `actions` is scanned alongside it, and is not padding - this repository +# publishes a release workflow that handles a tag and writes to the releases +# API, and that file is exactly the kind of thing the actions queries read. +# +# THIS FILE DOES NOT RUN YET, and that is not a mistake in it. GitHub disables +# advanced workflows while default setup is on, and default setup here is +# pinned by an enforced organisation configuration ("GitHub recommended"), +# so the repository cannot turn it off: the API answers 422, "controlled by +# organization administrators". +# +# The fix is not to detach this repository from that configuration. The same +# configuration enforces secret scanning, push protection, Dependabot alerts +# and private vulnerability reporting, and this repository is public with +# "secrets never enter the tree" as its first rule - trading push protection +# for Rust analysis would be a bad bargain. What is needed is a sibling +# configuration, identical but with code_scanning_default_setup disabled, +# attached to this repository alone. Then this file starts working and +# nothing else is given up. +name: CodeQL + +# Manual only, until the organisation configuration above is sorted. +# +# This ran once on push and did exactly what it should: the Rust extractor +# read the whole crate and produced a database, and then the upload was +# refused - "CodeQL analyses from advanced configurations cannot be +# processed when the default setup is enabled". The analysis is fine; the +# submission is what is blocked. +# +# A check that can only ever be red teaches nothing, and would be red on +# every push to main after the port lands. So the automatic triggers are +# parked here, in the order they should be restored: +# +# push: { branches: [main] } +# pull_request: { branches: [main] } +# schedule: [ { cron: "17 4 * * 1" } ] # weekly, as default setup had +# +# Put those back the moment the repository is attached to a configuration +# with code_scanning_default_setup disabled, and delete this comment. +on: + workflow_dispatch: + +permissions: {} + +jobs: + analyze: + name: Analyze ${{ matrix.language }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + # The three this needs and nothing else: read the code, write the + # findings, and read Actions metadata for the workflow scan. + contents: read + security-events: write + actions: read + strategy: + fail-fast: false + matrix: + language: [rust, actions] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Initialise CodeQL + uses: github/codeql-action/init@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4 + with: + languages: ${{ matrix.language }} + # security-extended over the default suite. This is fourteen + # binaries that read a machine's own state and hold three API + # tokens; the extra queries are the point of running this at all. + queries: security-extended + + - name: Analyse + uses: github/codeql-action/analyze@486fec2a3ea2626afcd8c7e9208b4f515078dd7e # codeql-bundle-v2.26.4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..64e4d1e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,119 @@ +# A tag produces a release: fourteen Linux binaries, their checksums, and a +# changelog built from the commit subjects since the last tag. +# +# Nothing here rewrites the source it is building. The version in Cargo.toml +# and the tag are checked against each other and the build stops if they +# disagree, rather than the workflow editing the manifest to make them match +# - a build that edits its own source produces a binary no checkout can +# reproduce, which is a strange thing to hand somebody. +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + linux: + # Pinned, not `-latest`. The oldest glibc a binary runs on is the one it + # was built against, so that is a decision rather than something to + # inherit from whatever the runner image became this month. 22.04 is + # glibc 2.35. + runs-on: ubuntu-22.04 + steps: + # Pinned to a commit, not a tag. A tag is mutable: re-running this + # workflow on the same release tag could execute different action code + # than the release was built with, which makes "reproducible" a word + # rather than a property. Comments carry the human-readable version so + # a reader can tell what these are without resolving them. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + # The changelog reads the history between two tags, and the build + # stamps the commit into --version. A shallow clone has neither. + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + # An exact version, for the same reason. `stable` moves, and two + # builds of one tag compiled by different compilers are two + # different binaries wearing one version number. + toolchain: "1.98.0" + + - name: The tag and the manifest must agree + run: | + tag="${GITHUB_REF_NAME#v}" + manifest=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) + if [ "$tag" != "$manifest" ]; then + echo "tag v$tag but Cargo.toml says $manifest" >&2 + echo "bump the manifest and tag that commit, rather than having" >&2 + echo "this workflow rewrite the source it is building" >&2 + exit 1 + fi + echo "v$tag" + + - name: The release profile still unwinds + run: | + # Three widgets wrap their poll thread in catch_unwind and record + # why it stopped. Under panic = "abort" those arms are unreachable + # and the source reads as protected while the binary is not - it + # was shipped that way once. Costs about 4.5% of the total size, + # measured. + grep -q '^panic = "unwind"$' Cargo.toml || { + echo "Cargo.toml no longer sets panic = unwind" >&2; exit 1; } + # And nothing here may quietly override it. + if [ -n "${RUSTFLAGS:-}" ]; then + echo "RUSTFLAGS is set ($RUSTFLAGS) and could undo the profile" >&2 + exit 1 + fi + + - run: cargo test --release + + - run: cargo build --release + + - name: Nothing dynamic beyond the C runtime + run: | + # rusqlite is taken with `bundled` so SQLite is compiled in, and + # the promise on the tin is that a binary needs nothing installed + # alongside it. Checked rather than asserted: a crate that starts + # wanting a system library would otherwise ship quietly. + allowed='linux-vdso|libc\.so|libm\.so|libgcc_s\.so|ld-linux' + bad=0 + for b in $(cargo metadata --no-deps --format-version 1 \ + | grep -o '"name":"[a-z-]*","src_path":"[^"]*bin/[a-z-]*\.rs"' \ + | sed 's/.*bin\///; s/\.rs"//'); do + [ -x "target/release/$b" ] || continue + leaked=$(ldd "target/release/$b" | grep -vE "$allowed" | grep -v '^\s*$' || true) + if [ -n "$leaked" ]; then + echo "$b links something it should carry:" >&2 + echo "$leaked" >&2 + bad=1 + fi + done + [ "$bad" = 0 ] + + - name: Changelog + run: ./.github/changelog.sh "$GITHUB_REF_NAME" > CHANGELOG-release.md + + - name: One tarball, and its checksum + run: | + name="terminal-toys-${GITHUB_REF_NAME}-x86_64-unknown-linux-gnu" + mkdir -p "dist/$name" + # The binaries, and the docs that explain them - `start` embeds + # them but a person reading the tarball has no other copy. + find target/release -maxdepth 1 -type f -executable ! -name '*.*' \ + -exec cp {} "dist/$name/" \; + cp -r docs README.md LICENSE config.example.json "dist/$name/" + tar -C dist -czf "$name.tar.gz" "$name" + sha256sum "$name.tar.gz" > "$name.tar.gz.sha256" + ls -la "$name.tar.gz" + + - name: Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --notes-file CHANGELOG-release.md \ + terminal-toys-*.tar.gz terminal-toys-*.tar.gz.sha256 diff --git a/.gitignore b/.gitignore index a88863d..24ded0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ -__pycache__/ -*.py[cod] -.venv/ -venv/ +target/ .DS_Store +# What a release build leaves behind, if one is run by hand. +dist/ +terminal-toys-*.tar.gz +terminal-toys-*.tar.gz.sha256 +CHANGELOG-release.md + # Personal settings, and now secrets: config.json holds the Vercel token. # Variants are covered too, so a config.local.json or a stray .env cannot be # committed by accident. diff --git a/AGENTS.md b/AGENTS.md index b9af0c9..234d3ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,23 +1,38 @@ # Working on terminal-toys Linear team: -Linear project: +Linear project: + +**Everything is tracked there** — planned widgets, the per-widget port +reviews, and the decisions waiting on William. Before starting anything, +look for the issue; before proposing something, check it is not already +filed and already decided against. ## What this repo is -Dependency-free Python 3 terminal widgets that look like sci-fi movie panels -and show only real data. Each widget is a single executable script sharing -`common.py`; there is no package, no build step and no third-party dependency. +Terminal widgets that look like sci-fi movie panels and show only real data. +Fourteen Rust binaries sharing `toys-core`, built with `cargo build +--release` from the root. + +They began as Python scripts and were ported widget by widget; the Python is +gone, and `docs/port-decisions.md` records what the port changed and why. The founding rule, and the one worth defending: **every number on screen is real.** Widgets that could not be wired to a true source were deleted rather -than faked. `matrix.py` is the sole exception and computes nothing on purpose. +than faked. `matrix` is the sole exception and computes nothing on purpose. ## Conventions -- **Python 3.9+, standard library only.** No pip installs, ever. If a widget - needs an external tool (`ping`, `tailscale`, `herdr`) it degrades gracefully - when that tool is absent. +- **What ships must carry what it needs.** Third-party dependencies are + allowed; a dependency that has to be installed separately before a widget + runs is not. The build step is what absorbs them - `rusqlite` is taken + with `bundled` so SQLite is compiled in, and `ldd` on a release binary + shows only libc, libm and libgcc. Keep it that way: a crate that wants a + system library at run time is the one kind that cannot come in. If a + widget needs an external *tool* + (`curl`, `ss`, `ping`, `tailscale`, `herdr`) it says so and stops, or goes + on without what that tool would have told it - that is a different thing + from a library and the rule is unchanged. - **Config, never hardcoded.** Hostnames, cities, tokens and account lists go in `config.json` (git-ignored) via `load_config()`. Add new keys to `config.example.json` in the same commit — and **use the section name the @@ -36,21 +51,73 @@ than faked. `matrix.py` is the sole exception and computes nothing on purpose. ## Before you commit -Run `python3 check.py`. It checks the things `compile()` cannot, and every -check in it exists because something shipped broken and looked, on screen, -exactly like "there is no data": +Run `cargo test` from the root. Alongside each widget's own tests it runs +`widgets/tests/check.rs`, which checks the things the compiler cannot, and +every check in it exists because something shipped broken and looked, on +screen, exactly like "there is no data": + +- **a poller that dies without recording why** — a thread that stops is + invisible, and the pane it feeds is indistinguishable from a quiet source. + Recording it is not enough: the reason has to reach a row, and a caught + panic ending in `unwrap_or_default()` is flagged on its own line, because + that shape hands the pane an empty list and draws a source with nothing in + it. Two widgets passed this check on accidents - one on the presence of + `catch_unwind` alone, one on a Bresenham variable called `err`; +- **a footer hint naming a key no match arm answers** — a hint bound to + nothing says the feature is there; +- **a footer hint missing from the widget's doc**; +- **a key the `--help` text names that nothing answers** — `--help` is where + someone looks when the footer was not enough, and until this check went in + nothing read those files at all; +- **a config key a widget reads that is not in `config.example.json`** — an + undiscoverable setting is not a setting; +- **a section in the example no widget reads**, and separately **a key in the + example the widget never reads** — checking the section alone passes a + widget that reads three of its four keys and ignores the fourth; +- **a bare `cfg.get()` with no fallback in the same statement** — `cfg_f64` + and its siblings take a default by signature, so they cannot go wrong; a + raw `get()` can, and then a key deleted from `config.json` lands on zero or + on a panic instead of on the widget's own default; +- **text on the selected-row tint measuring under AA 4.5** — the convention + above, which was prose here from before the port and went unmet in four + widgets for as long as there were four widgets. The number of places + the failing grey reached a tinted row grew from seventeen to twenty-three + while it sat open, which is what prose costs. Only colours that actually + meet are compared: the first version paired every tint in a file with every + grey in it and flagged two widgets that were fine; +- **a widget that defines a lighter grey and never reaches for it** — the + contrast check measures the colour and cannot see the wiring. Delete the + substitution inside the tint closure and the light grey sits in the palette + measuring beautifully while the dark one goes back on the tint, so the + substitutions are counted instead, one per closure that composes a tint. + +The hint reader sees `[k]` wherever it falls, four rules keeping `[{}]`, +`[::1]`, `[[bin]]` and `args[0]` out; the glyphs `↵ → ← ↑ ↓`; the names +`esc tab enter backspace pgup pgdn home end`; and, inside a footer, a bare +single letter — which is what catches `or i to close`. On the other side it +reads match arms and `key ==` / `key !=` comparisons alike. -- **unbound names** — a missing import only raises when the line runs, and in a - poll thread that means silence; -- **unguarded pollers** — a daemon thread that raises simply stops; -- **dead config keys** — a key in the example no widget reads; -- **missing docs / README rows**, and **footer keys absent from the doc**. +What it still cannot see: a key named in prose in a string that is not a +footer, and a key answered anywhere other than those two forms. Both halves +have been wrong before — three versions of this check cried wolf in one day, +and a checker that cries wolf gets turned off — so when it fires, read the +flag before believing it, and when it is quiet, that is not proof. + +The help reader is cruder still and admits it. Only two shapes count, a +letter right after `press` and a letter right before a verb, because reading +every single letter took the `a` out of "with a longer" and reported a key +called `a`. So in `Enter, i or c opens`, only `c` touches the verb and a +stale `i` beside it goes unseen — catching one of the two still lands the +reader in the right sentence. + +`check.py` covered the same ground for the Python, plus unbound names - +which the compiler now makes impossible. It went with the Python. ## Gotchas paid for already - **A background thread that dies is invisible.** Wrap every poller so it records why it stopped; otherwise the pane shows no data and no error, which - is indistinguishable from a source that has none. `deployments.py` sat like + is indistinguishable from a source that has none. `deployments` sat like that for a day. - **Never let a bare `except` swallow a programming error.** `discover_teams` turned a `TypeError` from passing the wrong type into "no teams found", and @@ -70,15 +137,64 @@ exactly like "there is no data": - **GitHub search returns at most 100 nodes per page.** Anything counting records must paginate or, better, ask for `issueCount` aggregates — which cost one rate-limit point per *request*, not per alias. +- **A secret scan of the diff cannot see commit messages.** Scanning every + added line before a push is right and still misses half the rule, because + the rule covers code, docs *and* messages, and a message is not a diff + line. Scan `git log origin/main..HEAD` separately. +- **A grep that finds nothing is as often a wrong pattern as an absent + thing.** This was the most repeated mistake of the Rust port, on both + sides: `[a-z0-9]+` could not match the uppercase half of `"q" | "Q"` and + reported 48 widgets broken; a config audit read line by line and silently + skipped every multi-line `cfg\n.get(...)` chain, which is most of them; + another assumed the config variable was named `cfg` and declared six keys + unread that are reached through `&gh` and `&raw`; a claim that netwatch + ignored two keys came from grepping only for `tc::cfg_*`. Before believing + a zero, run the pattern against a case you know it should match. +- **A new check is green against your working tree, not against the repo.** + A check written beside an uncommitted fix is measured against the fix. One + shipped passing here and failed on a clean checkout of its own commit, + because the stale line it was written to catch had already been corrected + in another session's dirty tree. Run a new check against `HEAD` — stash, + or `git show HEAD:` the files it reads — before believing it. +- **A build script that emits any `rerun-if-*` stops watching files.** The + rule that cargo reruns a script when any file in the package changes + applies only to a script emitting no `rerun-if-*` directive at all. One + `rerun-if-env-changed` is enough to override it, so deleting the + `rerun-if-changed` watches here did not make the version stamp always + rebuild - it made it rebuild almost never, and `--version` reported a + four-commit-old sha with a stale `-dirty` while the tree was clean. A + full thirty-second rebuild did not move it. Watching nothing and watching + everything are both wrong: unconditional reruns relink all fourteen + binaries on every no-op build, measured at 28s against 0.04s. Watch the + git files `git rev-parse --git-path` resolves - the only form that works + in a linked worktree, where `.git` is a file - plus the source trees. +- **The commit that removes a secret is the likeliest place to restate it.** + "The fixture used ``, which is a device on this tailnet" + is the most natural sentence to write when documenting the fix, and it + says more than the fixture did — it confirms the string is real and + explains what it identifies. Describe the shape, never the value: *a + fixture named a real device* is enough. ## Layout of the code -`common.py` holds everything shared: terminal sizing, full-frame `draw()`, -24-bit `rgb()`, `seg()` for clipping coloured segments to a cell budget, -`pack_hints()`, bar and chart helpers (`vbars`, `vbars_down`, `braille_plot`, -`stacked_bar`, `meter`, `skeleton`), `config_token_warning()` for -widgets holding a secret, non-blocking `Keyboard`, and OSC 52 -`clipboard()`. +`toys-core` holds everything shared: terminal sizing, full-frame `draw()`, +24-bit `rgb()` and the green→amber→red `heat()` ramp, `seg()` for clipping +coloured segments to a cell budget, `pack_hints()`, `follow()` for a window +that keeps a cursor in view, bar and chart helpers (`vbars`, `vbars_down`, +`stacked_bar`, `meter`, `skeleton`), `get()` and `post_json()` over `curl`, +`config_token_warning()` for widgets holding a secret, non-blocking +`Keyboard`, and OSC 52 `clipboard()`. + +Braille line charts are not in there. `latency` and `link` each keep their +own `braille_canvas`, and the two are not the same function: latency's series +carries the gaps a ping can leave, link's is told how many slots the axis +holds. Copy from whichever is closer rather than expecting core to have one. + +`docs/port-decisions.md` records what the port changed from the Python and +why - the keys it consolidated, the three it renamed, the charts it draws +differently, and what was built afterwards on the Rust side alone. It is +history rather than a comparison now, and it is the answer to most questions +beginning *why does this key do that*. `docs/building-herdr-panels.md` records what was learned driving these from Herdr: resize semantics, focus, and the layout mistakes worth skipping. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f52c325 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,556 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toys-core" +version = "0.1.0" +dependencies = [ + "chrono", + "chrono-tz", + "libc", + "serde_json", +] + +[[package]] +name = "toys-widgets" +version = "0.1.0" +dependencies = [ + "chrono", + "chrono-tz", + "libc", + "rusqlite", + "serde_json", + "toys-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..686e370 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[workspace] +resolver = "2" +members = ["core", "widgets"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-or-later" + +# Thirteen of these run at once, so size is worth more here than the last +# few percent of speed: optimise for it, strip symbols, and let LTO drop +# everything nothing reaches. +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +# unwind, deliberately, and not for free: it costs about 0.5 MB across the +# fourteen binaries, 4.5%, measured. It buys the poller guards in usage, +# herdr-panes and ports, which wrap their poll threads in catch_unwind and +# record why they stopped. Under panic = "abort" those guards cannot fire +# at all - the process dies before the Err arm is reached - so the source +# read as protected while the shipped binary had no protection, and the +# tests exercised a path that only exists in the test profile. CLAUDE.md +# asks that every poller record why it stopped; this is what makes that +# true rather than aspirational. Setting this back to "abort" silently +# deletes three error paths. +panic = "unwind" +strip = true diff --git a/README.md b/README.md index 92f2285..b5da4b5 100644 --- a/README.md +++ b/README.md @@ -26,43 +26,47 @@ impossible to unsee. One by one the fakes came down, each replaced by something that answers a real question. What survived is the aesthetic with the lying removed. -Some of the theatre is still here, unapologetically. `matrix.py` computes +Some of the theatre is still here, unapologetically. `matrix` computes nothing at all — it just looks good, and it knows it. ## The widgets | Widget | What it does | Needs | Docs | |---|---|---|---| -| **`start.py`** | The front door: every widget, what it does, and whether it will work on this machine — pick one and it runs, quit it and you are back. | — | [read →](docs/start.md) | -| **`latency.py`** | Continuous latency to a list of hosts: median, jitter, loss and a log-scale graph, so a slow link and an *unsteady* one look different. | `ping` | [read →](docs/latency.md) | -| **`deployments.py`** | Vercel deployments over time — activity per hour, build-time drift, and a copy sheet for the dashboard, preview and PR URLs. | a Vercel token | [read →](docs/deployments.md) | -| **`tailnet.py`** | Tailscale peers, and whether each is reached directly or through a relay. Live throughput, full machine info, copyable addresses. | `tailscale` | [read →](docs/tailnet.md) | -| **`herdr-panes.py`** | Every agent and process across all workspaces, ordered by which one needs a human. Enter jumps you there. | `herdr` | [read →](docs/herdr-panes.md) | -| **`github.py`** | Pull requests across every org: merge rate, opened-vs-merged per day, review backlog and the contribution calendar. | a GitHub token | [read →](docs/github.md) | -| **`pr.py`** | The pull requests you have to follow up on: checks, reviews, mergeability, and a stack map with the order a stack has to merge in. | a GitHub token | [read →](docs/pr.md) | -| **`linear.py`** | Linear across every team: what is outstanding, the running cycles and their scope creep, and issues created against completed. | a Linear API key | [read →](docs/linear.md) | -| **`usage.py`** | How much each coding agent on the machine has been used — tokens, sessions, AI-written code — and what is left of each one's rate limit, one tab per agent. | the agents' own logins | [read →](docs/usage.md) | -| **`ports.py`** | What is listening on this machine — the dev servers you have running, which project each was started from, how long it has been up, and whether anything outside the box can reach it. `k` stops the selected one; `↵` opens it to copy an address or publish it over Tailscale or Cloudflare. | — | [read →](docs/ports.md) | -| **`netwatch.py`** | Which processes are using the network — total since it started, current rate, up and down, per process — read from the kernel's own per-socket counters rather than by capturing packets. | `ss` | [read →](docs/netwatch.md) | -| **`link.py`** | How good the connection is between this machine and whoever is connected to it — round-trip time, jitter, loss and achieved rate for every inbound session, read from the kernel rather than probed. | `ss` | [read →](docs/link.md) | -| **`clocks.py`** | Server clock, countdowns to the next hour / end of office hours / end of day, a pomodoro, and a world clock. | — | [read →](docs/clocks.md) | -| **`matrix.py`** | Nothing whatsoever. Digital rain, with truecolor fade trails. | — | — | - -Each is a single self-contained script with no dependencies — pure Python 3 -standard library, 24-bit colour, and a full redraw each frame so everything +| **`start`** | The front door: every widget, what it does, and whether it will work on this machine — pick one and it runs, quit it and you are back. | — | [read →](docs/start.md) | +| **`latency`** | Continuous latency to a list of hosts: median, jitter, loss and a log-scale graph, so a slow link and an *unsteady* one look different. | `ping` | [read →](docs/latency.md) | +| **`deployments`** | Vercel deployments over time — activity per hour, build-time drift, and the build log of the one you open, so a failure explains itself instead of only naming a code. A copy page carries the dashboard, preview and PR URLs. | `curl`, a Vercel token | [read →](docs/deployments.md) | +| **`tailnet`** | Tailscale peers, and whether each is reached directly or through a relay. Live throughput, full machine info, copyable addresses. | `tailscale` | [read →](docs/tailnet.md) | +| **`herdr-panes`** | Every agent and process across all workspaces, ordered by which one needs a human. Enter jumps you there. | `herdr` | [read →](docs/herdr-panes.md) | +| **`github`** | Pull requests across every org: merge rate, opened-vs-merged per day, review backlog and the contribution calendar — and `↵` for one account on a screen of its own, because a queue growing in one of them is invisible in a total the others are also feeding. | `curl`, a GitHub token | [read →](docs/github.md) | +| **`pr`** | The pull requests you have to follow up on: checks, reviews, mergeability, and a stack map with the order a stack has to merge in. | `curl`, a GitHub token | [read →](docs/pr.md) | +| **`linear`** | Linear across every team: what is outstanding, the running cycles and their scope creep, issues created against completed, and every project still going. `↵` opens a cycle, a team or a project on a screen of its own. | `curl`, a Linear API key | [read →](docs/linear.md) | +| **`usage`** | How much each coding agent on the machine has been used — tokens, sessions, AI-written code — and what is left of each one's rate limit, one tab per agent. | the agents' own logins | [read →](docs/usage.md) | +| **`ports`** | What is listening on this machine — the dev servers you have running, which project each was started from, how long it has been up, how much traffic it is carrying, and whether anything outside the box can reach it. `k` stops the selected one; `↵` opens it for a traffic chart, an address to copy, or a publish over Tailscale or Cloudflare. | `ss` for the traffic | [read →](docs/ports.md) | +| **`netwatch`** | Which processes are using the network — total since it started, current rate, up and down, per process — read from the kernel's own per-socket counters rather than by capturing packets. | `ss` | [read →](docs/netwatch.md) | +| **`link`** | How good the connection is between this machine and whoever is connected to it — round-trip time, jitter, loss and achieved rate for every inbound session, read from the kernel rather than probed. | `ss` | [read →](docs/link.md) | +| **`clocks`** | Server clock, countdowns to the next hour / end of office hours / end of day, a pomodoro, and a world clock. | — | [read →](docs/clocks.md) | +| **`matrix`** | Nothing whatsoever. Digital rain, with truecolor fade trails. | — | — | + +Each is a single self-contained binary — every library it needs is compiled +in, `ldd` shows only libc, libm and libgcc, and there is nothing to install +alongside it. 24-bit colour, and a full redraw each frame so everything reflows when you resize the pane. ```sh -python3 terminal-toys # the front door: pick one and it runs -./start.py # the same thing from inside the directory -./start.py latency # or name one and skip the menu +cargo build --release # fourteen binaries in ./target/release +``` + +```sh +./target/release/start # the front door: pick one and it runs +./target/release/start latency # or name one and skip the menu ``` Each widget is also an ordinary program, if you would rather go direct: ```sh -./latency.py # each runs standalone -./clocks.py -h # every widget documents itself +./target/release/latency # each runs standalone +./target/release/clocks -h # every widget documents itself ``` They are built to sit side by side and fill a wall, but nothing assumes a @@ -70,8 +74,8 @@ multiplexer — each is an ordinary terminal program. Tile them however you like ## Building the wall -Ten widgets tile into whatever space you have. A layout that works on a wide -screen: +Fourteen widgets tile into whatever space you have. A layout that works on a +wide screen: ``` ┌────────────────────┬──────────────────┬────────────┐ @@ -87,57 +91,47 @@ widget in a 30-column strip still says something useful. ## Configuration -Every widget reads optional settings from the first of -`$TERMINAL_TOYS_CONFIG`, `~/.config/terminal-toys/config.json`, or -`config.json` beside the scripts. Copy `config.example.json` to start. +Every widget reads optional settings from the first readable of +`$TERMINAL_TOYS_CONFIG`, `$XDG_CONFIG_HOME/terminal-toys/config.json` +(`~/.config/terminal-toys/config.json` where that is unset), `config.json` in +the working directory, and `config.json` beside the binary. Copy +`config.example.json` to start. This keeps hostnames, ping targets, city lists and tokens out of the source tree: the repo ships generic defaults, and `config.json` is git-ignored along with `.env` files and anything else likely to hold a secret. -**Three widgets need a token:** `deployments.py` wants a Vercel token from -Account Settings → Tokens, `github.py` a *classic* GitHub PAT with `repo` and -`read:org` (fine-grained tokens reach only one org each), and `linear.py` a -personal API key from Settings → Security & access. `pr.py` reuses the GitHub +**Three widgets need a token:** `deployments` wants a Vercel token from +Account Settings → Tokens, `github` a *classic* GitHub PAT with `repo` and +`read:org` (fine-grained tokens reach only one org each), and `linear` a +personal API key from Settings → Security & access. `pr` reuses the GitHub token rather than asking for its own. Every other widget runs with no configuration at all. ## Requirements -- Python **3.9+** (`clocks.py` uses `zoneinfo`); developed on 3.12 +- A Rust toolchain to build; **no library to install** to run — the binaries + carry what they link against, SQLite included - A terminal with 24-bit colour -- Per-widget: `ping`, `tailscale` or `herdr` as listed above. Each needs only - its own, and **none needs root** - -## Design - -A few conventions hold across all of them, and the reasoning is worth knowing -before changing one: - -- **Spend extra width on more content, not padding.** Widgets add columns as a - pane grows and drop them as it shrinks, rather than truncating. -- **Never truncate a key hint.** Footers wrap across as many lines as they need - and never split a hint, because `[±]25` teaches a key that does not exist. -- **A directional glyph points the way the thing goes.** `▲`/`▼` mark which - half of a diverging chart a series occupies — `▲ opened` above the baseline, - `▼ merged` below it. `↑`/`↓` mean upload and download. Where both meanings - meet, in `netwatch.py`'s chart, the halves are arranged so they agree: tx - above and rx below, because a `↓` label over a line that climbs asks the - reader to hold two directions at once, and they will believe the arrow. -- **Measure contrast, do not eyeball it.** Every colour that draws text clears - WCAG AA against both the terminal background *and* the selected-row tint, - with the measured ratios recorded beside the definitions. -- **Say what a number means when it is not obvious.** Counters that reset with - a daemon, durations that predate the process, aggregates that hide their - outliers — each is labelled rather than left to mislead. -- **Never show a stale figure under a fresh label.** Change a setting and the - numbers it governs shimmer until real ones land, rather than sitting there - looking current. The same rule kills silent truncation: a chart that cannot - fit its window says `54d of 90d`, and a token missing a scope is named rather - than left to quietly undercount. -- **Optional enhancements, never requirements.** The clipboard goes through - OSC 52 so it survives SSH; Herdr toasts and `sudo`-gated data are added where - available and skipped silently where not. +- Per-widget, the external *tools* the table above names: `curl`, `ss`, + `ping`, `tailscale`, `herdr`. Each widget needs only its own; one that + cannot work without its tool says so rather than drawing an empty pane; and + **none needs root** + +## Documentation + +Every widget has a page of its own — what it shows, where each number comes +from, every key it answers to, and the settings it reads. They are linked +from the table above, and listed together in [`docs/`](docs/README.md). + +Four pages are about the repository rather than a widget: + +| | | +|---|---| +| [Design conventions](docs/design.md) | the rules every widget holds to, and why each was paid for | +| [Internals](docs/internals.md) | `toys-core`, the chart helpers, and what `cargo test` checks that a compiler cannot | +| [Port decisions](docs/port-decisions.md) | what the Rust port changed from the Python and why — the answer to most questions beginning *why does this key do that* | +| [Building Herdr panels](docs/building-herdr-panels.md) | resize semantics, focus, and the layout mistakes worth skipping | ## Bundled skill @@ -147,23 +141,21 @@ for it — copy it to `~/.claude/skills/herdr/` to install. It is Herdr's own file, not covered by this repository's licence, and `herdr --skill` regenerates it after an upgrade. -## Building your own +## What is being worked on -[`docs/building-herdr-panels.md`](docs/building-herdr-panels.md) collects what -was learned building these against Herdr: resize semantics, focus, detecting -what a pane is running, notification gating, and the layout mistakes worth -skipping. +Planned widgets, open questions and the state of the Rust port are tracked +in Linear: . The link needs access to the workspace; the issues are the +canonical list either way, so a feature that looks missing may already be +filed there with a reason. -`common.py` holds the shared pieces — terminal sizing, a full-frame `draw()`, -24-bit colour, a green→amber→red `heat()` ramp, `seg()` for clipping coloured -text to a cell budget, `pack_hints()` for wrapping footers, non-blocking -`Keyboard` input with arrow-key decoding, and `clipboard()` over OSC 52. +## Contributing -The chart helpers are worth knowing before drawing anything new: `vbars()` and -its mirror `vbars_down()` (pair them on a shared scale for a diverging chart), -`braille_plot()` for continuous lines at 2×4 sub-pixels a cell, `stacked_bar()` -for proportions, `meter()` for a gauge, and `skeleton()` for the shimmer that -stands in for a figure still being fetched. +`cargo test` from the root is the gate: each widget's own tests, plus +[`widgets/tests/check.rs`](widgets/tests/check.rs), which reads the sources +and fails on the things a compiler cannot see — a poller that dies without +saying why, a key hinted but unanswered, a setting read but undocumented, a +colour below WCAG AA on a selected row. [Internals](docs/internals.md) +explains what each check is defending. ## License diff --git a/__main__.py b/__main__.py deleted file mode 100644 index 05b8a7a..0000000 --- a/__main__.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Makes the directory itself runnable: `python3 terminal-toys`. - -Python's own convention for an entry point, so the collection can be started -without knowing which file inside it to name. Everything it does is in -start.py; this is the doorbell, not the door. -""" -import os -import runpy -import sys - -HERE = os.path.dirname(os.path.abspath(__file__)) -sys.argv[0] = os.path.join(HERE, "start.py") -runpy.run_path(sys.argv[0], run_name="__main__") diff --git a/check.py b/check.py deleted file mode 100755 index 254d6c6..0000000 --- a/check.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Checks for the failure modes these widgets actually had. - - python3 check.py - -Every check here exists because something shipped broken. `compile()` catches -none of them: each is a runtime or presentation fault that looks, on screen, -exactly like "there is no data". -""" -import ast -import builtins -import glob -import json -import os -import re -import sys - -os.chdir(os.path.dirname(os.path.abspath(__file__))) -# The library, the checker, the launcher and the directory's entry point are -# not widgets: none of them draws a panel, and none has a doc page of the -# shape these checks look for. -WIDGETS = [f for f in sorted(glob.glob("*.py")) - if f not in ("common.py", "check.py", "start.py", "__main__.py")] -PROBLEMS = [] - - -def fail(check, where, detail): - PROBLEMS.append((check, where, detail)) - - -def bound_names(tree): - out = {"__file__", "__name__", "__doc__"} - for n in ast.walk(tree): - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - out.add(n.name) - args = getattr(n, "args", None) - if args: - for grp in (args.posonlyargs, args.args, args.kwonlyargs): - out |= {a.arg for a in grp} - for a in (args.vararg, args.kwarg): - if a: - out.add(a.arg) - elif isinstance(n, ast.Lambda): - for grp in (n.args.posonlyargs, n.args.args, n.args.kwonlyargs): - out |= {a.arg for a in grp} - elif isinstance(n, (ast.Import, ast.ImportFrom)): - out |= {(a.asname or a.name).split(".")[0] for a in n.names} - elif isinstance(n, ast.Name) and isinstance(n.ctx, (ast.Store, ast.Del)): - out.add(n.id) - elif isinstance(n, ast.ExceptHandler) and n.name: - out.add(n.name) - elif isinstance(n, ast.Global): - out |= set(n.names) - return out - - -def check_unbound(): - """A name used but never bound raises NameError only when reached. - - deployments.py lost `config_token_warning` from its imports and its poll - thread died on the first iteration for a day, showing an empty board. - """ - for f in WIDGETS + ["common.py"]: - tree = ast.parse(open(f).read()) - used = {n.id for n in ast.walk(tree) - if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)} - for name in sorted(used - bound_names(tree) - set(dir(builtins))): - fail("unbound name", f, name) - - -def check_poller_guarded(): - """A daemon thread that raises simply stops. - - The widget then shows no data and no error, which is indistinguishable - from a source that genuinely has none. - """ - for f in WIDGETS: - src = open(f).read() - if "threading.Thread" not in src: - continue - tree = ast.parse(src) - ok = False - for n in ast.walk(tree): - if (isinstance(n, ast.FunctionDef) and n.name in ("run", "reader") - and any(isinstance(b, ast.Try) for b in n.body)): - ok = True - if not ok: - fail("unguarded poller", f, "run()/reader() may die silently") - - -def check_config_keys(): - """A key in the example the widget never reads is a lie in a sample file.""" - try: - example = json.load(open("config.example.json")) - except (OSError, ValueError) as e: - return fail("config.example.json", "-", str(e)[:60]) - # a section can be read by more than one widget - pr.py borrows github's - # token - so a key is only dead if *nothing* reads it - known = {} - for f in WIDGETS: - for m in re.finditer(r'load_config\(\s*"(\w+)"\s*,\s*\{(.*?)\n\}\)', - open(f).read(), re.S): - known.setdefault(m.group(1), set()) - known[m.group(1)] |= set(re.findall(r'"(\w+)":', m.group(2))) - for section, keys in known.items(): - shipped = {k for k in example.get(section, {}) if not k.startswith("_")} - for dead in sorted(shipped - keys): - fail("dead config key", section, dead) - - -def check_docs(): - """Every widget carries a doc page and a README row, or says why not.""" - readme = open("README.md").read() - for f in WIDGETS: - if f == "matrix.py": # decorative, deliberately undocumented - continue - if not os.path.exists("docs/%s.md" % f[:-3]): - fail("missing doc", f, "docs/%s.md" % f[:-3]) - if "`%s`" % f not in readme: - fail("missing README row", f, "not in the widget table") - - -def check_keys_documented(): - """A documented key that does not exist teaches a lie; so does the reverse.""" - for f in WIDGETS: - doc = "docs/%s.md" % f[:-3] - if not os.path.exists(doc): - continue - src, text = open(f).read(), open(doc).read() - handled = set(re.findall(r'key\s*(?:==|in\s*\()\s*[("]([a-z0-9])["\)]', - src)) - # a footer hint is "[w]indow" or "[r]efresh": the bracket is followed - # straight away by the rest of the word. Indexing like rows[0] is not. - hinted = set(re.findall(r'\[([a-z0-9])\](?=[a-z])', - " ".join(re.findall(r'"([^"\n]*)"', src)))) - for k in sorted(hinted - set(re.findall(r'`([a-z0-9])`', text))): - fail("undocumented key", f, "[%s] in the footer, not in %s" % (k, doc)) - - -for fn in (check_unbound, check_poller_guarded, check_config_keys, - check_docs, check_keys_documented): - fn() - -if not PROBLEMS: - print("all checks pass across %d widgets" % len(WIDGETS)) - raise SystemExit(0) -width = max(len(p[0]) for p in PROBLEMS) -for check, where, detail in PROBLEMS: - print("%-*s %-18s %s" % (width, check, where, detail)) -print("\n%d problem(s)" % len(PROBLEMS)) -raise SystemExit(1) diff --git a/clocks.py b/clocks.py deleted file mode 100755 index 5c61a46..0000000 --- a/clocks.py +++ /dev/null @@ -1,701 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Clocks: this server's, everyone else's, and the ones counting down. - -A big clock in the machine's own timezone, countdown bars for the next hour, -office hours and the end of day, an optional pomodoro, and a world clock -covering the hubs you care about. - -The pomodoro is off until you press p. It runs the standard 25/5 with a longer -break every fourth session, all configurable, and persists across restarts so -relaunching the panel does not cost you a session. - -A phase does not end itself. When the time is up the counter keeps going, -showing how far over you are, and the bar rescales so a growing red section -represents the overrun — the longer you ignore it, the more of the bar is red. -The whole panel also flashes twice, a second apart, on every alert - visible -with the sound muted. The terminal is alerted when the phase elapses and again -every minute it keeps running: BEL plus OSC 9 and OSC 777 desktop notifications, which are the only -channels that survive SSH. Under Herdr it additionally raises a native toast -with a sound — additive, never required, and skipped entirely elsewhere. - -Keys: up/down (and PgUp/PgDn, Home/End) scroll the city list while the clock, -countdowns and footer stay pinned. p shows or hides the pomodoro and suspends -it with them, space pauses or -resumes, r restarts the phase, s starts a break during focus and ends one during -a break - b and e do the same, and the footer names whichever applies - +/- -change the focus length, ? hides or shows the pomodoro controls, -0 zeroes today's completed count, q quits. - -The completed tally is per day: it resets when the date changes, including -while the panel is running. Preferences - focus length, whether the timer is -shown - are not tied to the day and persist. - -Big digits show this server's system-timezone clock. Below it, each hub -is shown in its own timezone, sorted west to east, coloured by whether people -there are plausibly at work. -""" -import datetime -import json -import os -import re -import subprocess -import sys -import time -from zoneinfo import ZoneInfo - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (RST, Keyboard, bar, bg, big, draw, flush, load_config, - maybe_help, out, pack_hints, pad, rgb, seg, setup, size, - title) - -# None = follow the system timezone for the big digits. -TZ = None - -_CFG = load_config("clocks", { - "cities": [ - ["San Francisco", "America/Los_Angeles"], - ["New York", "America/New_York"], - ["London", "Europe/London"], - ["Berlin", "Europe/Berlin"], - ["Bengaluru", "Asia/Kolkata"], - ["Singapore", "Asia/Singapore"], - ["Tokyo", "Asia/Tokyo"], - ["Sydney", "Australia/Sydney"], - ], - "work_start_hour": 9, - "work_end_hour": 18, - "pomodoro_enabled": False, - "pomodoro_focus_minutes": 25, - "pomodoro_short_break_minutes": 5, - "pomodoro_long_break_minutes": 15, - "pomodoro_sessions_before_long_break": 4, - "pomodoro_bell": True, # terminal bell on elapse and each minute over - "pomodoro_notify": True, # OSC 9 desktop notification, where supported - "pomodoro_flash": True, # flash the panel when an alert fires - "pomodoro_flash_count": 2, # how many flashes - "pomodoro_flash_gap": 1.0, # seconds between them - "pomodoro_flash_rgb": [246, 248, 252], # flash colour; near-white by default - "show_hints": True, # key hints along the bottom; ? toggles -}) - -CITIES = [tuple(c) for c in _CFG["cities"]] - - -WORK_START_H = int(_CFG["work_start_hour"]) -WORK_END_H = int(_CFG["work_end_hour"]) - -# Pomodoro: 25 minutes of focus, 5 off, a longer break every fourth session. -PHASES = ("focus", "short", "long") -PHASE_LABEL = {"focus": "FOCUS", "short": "SHORT BREAK", "long": "LONG BREAK"} -STATE_FILE = os.path.join( - os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), - "terminal-toys", "pomodoro.json") - -C1 = rgb(120, 255, 200) -PURPLE = rgb(175, 130, 255) -HOUR = rgb(90, 220, 255) -FOCUS = rgb(255, 130, 120) -BREAK = rgb(120, 235, 170) -PAUSED = rgb(160, 172, 190) -OVER = rgb(255, 80, 90) -C2 = rgb(40, 150, 120) -DIM = rgb(70, 130, 110) -TXT = rgb(220, 255, 240) -WORK = rgb(130, 255, 180) # inside working hours -EVE = rgb(255, 200, 90) # evening -NIGHT = rgb(95, 130, 175) # asleep -WEEKEND = rgb(150, 150, 170) -HERE = rgb(255, 170, 220) - - -ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") -FLASH_ON = 0.35 # seconds each flash stays lit -_FLASH_RGB = tuple(int(c) for c in _CFG["pomodoro_flash_rgb"])[:3] -FLASH_BG = bg(*_FLASH_RGB) -# Text colour follows the flash colour rather than being fixed, so a custom -# dark flash stays readable instead of turning the panel into a black square. -_FLASH_LUM = (0.2126 * _FLASH_RGB[0] + 0.7152 * _FLASH_RGB[1] - + 0.0722 * _FLASH_RGB[2]) / 255.0 -FLASH_FG = rgb(18, 20, 26) if _FLASH_LUM > 0.5 else rgb(255, 240, 240) - - -def flash_window(started, count, gap): - """Is the panel lit right now? - - Flashes are derived from one timestamp rather than driven by sleeps, so - the render loop keeps running - the clock stays live and keys stay - responsive while it blinks. - """ - if not started: - return False - since = time.time() - started - for n in range(max(1, count)): - edge = n * gap - if edge <= since < edge + FLASH_ON: - return True - return False - - -def flash_frame(rows, w, h): - """The same frame, repainted solid: colours stripped, one loud background.""" - out_rows = [] - for i in range(h): - text = ANSI_RE.sub("", rows[i]) if i < len(rows) else "" - out_rows.append(FLASH_BG + FLASH_FG + pad(text, w)) - return out_rows - - -# Herdr, when we happen to be inside it, can raise a real toast with a sound. -# Purely additive: nothing here requires Herdr, and outside it this is skipped. -UNDER_HERDR = os.environ.get("HERDR_ENV") == "1" - - -def herdr_toast(title, body, sound="done"): - """Best-effort native Herdr notification; a no-op anywhere else. - - Fire and forget: waiting on a subprocess would stall the render loop, and - a failed toast is not worth interrupting a timer for. Herdr itself decides - whether to display it, per `[ui.toast] delivery` in its config. - """ - if not UNDER_HERDR: - return - try: - subprocess.Popen(["herdr", "notification", "show", title, - "--body", body, "--sound", sound], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except OSError: - pass - - -def alert(text, bell=True, notify=True, sound="done"): - """Nudge the user through the terminal, requiring nothing but a terminal. - - Escape sequences are the only alerting channel that survives SSH: the - program runs on a server, so anything local to it - notify-send, a sound - file - would fire where nobody is sitting. These reach the terminal the - user is actually in front of. - - BEL is universal. OSC 9 covers iTerm2, WezTerm, Windows Terminal and - Ghostty; OSC 777 covers urxvt and several others. Terminals ignore the - notification sequences they do not implement, so sending both costs - nothing. A multiplexer in between decides whether to forward them. - """ - if bell: - out("\a") - if notify: - out("\x1b]9;%s\x07" % text) # iTerm2 & friends - out("\x1b]777;notify;Pomodoro;%s\x07" % text) # urxvt & friends - flush() - if notify: - herdr_toast("Pomodoro", text, sound) - - -class Pomodoro(object): - """A pomodoro that survives the panel being restarted. - - Panels get relaunched often, and losing a 20-minute session to that would - make the timer useless, so phase and elapsed time are persisted and - reloaded. Time is tracked as an absolute deadline rather than by counting - down, so a stalled or slow redraw cannot make the timer drift. - """ - - def __init__(self): - self.focus = int(_CFG["pomodoro_focus_minutes"]) - self.short = int(_CFG["pomodoro_short_break_minutes"]) - self.long = int(_CFG["pomodoro_long_break_minutes"]) - self.cycle = int(_CFG["pomodoro_sessions_before_long_break"]) - self.enabled = bool(_CFG["pomodoro_enabled"]) - self.phase = "focus" - self.completed = 0 - self.running = False - self.left = self.focus * 60.0 # seconds remaining when paused - self.deadline = None # wall-clock end when running - self.rang = False - self.day = time.strftime("%Y-%m-%d") - self.was_running = False # run state to restore when unhidden - # A display preference rather than timer state, but it rides along in - # the same file so the panel comes back looking how you left it. - self.hints = bool(_CFG["show_hints"]) - self.nagged = 0 # whole minutes of overtime already alerted - self.bell = bool(_CFG["pomodoro_bell"]) - self.notify = bool(_CFG["pomodoro_notify"]) - self._load() - - # ---- persistence ------------------------------------------------- - def _load(self): - try: - with open(STATE_FILE) as f: - d = json.load(f) - except (OSError, ValueError): - return - # Preferences outlive the day; only the tally and the block in - # progress belong to it. - self.focus = int(d.get("focus", self.focus)) - self.enabled = bool(d.get("enabled", self.enabled)) - self.hints = bool(d.get("hints", self.hints)) - if d.get("day") != self.day: - return # a new day starts a fresh count - self.phase = d.get("phase", self.phase) - self.completed = int(d.get("completed", 0)) - self.was_running = bool(d.get("was_running", False)) - self.left = float(d.get("left", self.left)) - if d.get("running") and d.get("deadline"): - # resume mid-phase; if it elapsed while we were away the timer - # simply shows how far over it has run - self.deadline = float(d["deadline"]) - self.running = True - self.rang = self.signed() <= 0 - - def save(self): - try: - os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) - with open(STATE_FILE, "w") as f: - json.dump({"day": self.day, "phase": self.phase, - "completed": self.completed, "focus": self.focus, - "enabled": self.enabled, "running": self.running, - "was_running": self.was_running, - "hints": self.hints, - "left": self.left, "deadline": self.deadline}, f) - except OSError: - pass - - # ---- state ------------------------------------------------------- - def duration(self): - return {"focus": self.focus, "short": self.short, - "long": self.long}[self.phase] * 60.0 - - def signed(self): - """Seconds left; negative once the phase has been overrun.""" - if self.running and self.deadline: - return self.deadline - time.time() - return self.left - - def remaining(self): - return max(0.0, self.signed()) - - def overtime(self): - return max(0.0, -self.signed()) - - def toggle(self): - """Show/hide, and suspend with it. - - A timer that keeps counting while hidden is worse than no timer: you - come back to a focus block that expired half an hour ago. Hiding - freezes it where it stands, and showing resumes it only if it was - running when it went away. - """ - if self.enabled: - self.was_running = self.running - if self.running: - self.pause() # freezes, preserving any overtime - self.enabled = False - else: - self.enabled = True - if self.was_running and not self.running: - self.pause() # resume exactly where it stopped - self.save() - - def pause(self): - if self.running: - self.left = self.signed() # keeps any overtime accrued - self.running, self.deadline = False, None - else: - self.deadline = time.time() + self.signed() - self.running = True - self.save() - - def restart(self): - self.left = self.duration() - self.deadline = time.time() + self.left if self.running else None - self.rang = False - self.nagged = 0 - self.save() - - def advance(self): - """Move to the next phase; a long break every `cycle` focus sessions. - - Keeps whatever run state it had: skipping out of a running focus block - starts the break immediately, which is what "I am done, move on" means. - """ - if self.phase == "focus": - self.completed += 1 - self.phase = ("long" if self.cycle and self.completed % self.cycle == 0 - else "short") - else: - self.phase = "focus" - self.left = self.duration() - self.deadline = time.time() + self.left if self.running else None - self.rang = False - self.nagged = 0 - self.save() - - def next_label(self): - """What pressing the advance key will actually do, right now. - - The same key starts a break during focus and ends one during a break, - so a fixed label like "skip" describes neither. - """ - if self.phase != "focus": - return "[e]nd break" - upcoming_long = self.cycle and (self.completed + 1) % self.cycle == 0 - return "[s]tart long break" if upcoming_long else "[s]tart break" - - def roll_day(self): - """Zero the tally when the date changes, even if nothing restarted. - - The count previously reset only on load, so a panel left running over - midnight kept adding to yesterday's total. - """ - today = time.strftime("%Y-%m-%d") - if today != self.day: - self.day = today - self.completed = 0 - self.save() - return True - return False - - def reset_count(self): - """Zero today's completed count, leaving the running phase alone.""" - self.completed = 0 - self.save() - - def adjust(self, delta): - """Change the focus length, shifting the block in progress by the same. - - The bar divides by duration() while the counter reads the deadline, so - changing one without the other made them disagree - the bar moved and - the countdown sat still. Both now shift together: +5 means five more - minutes on the clock, whether the block is running, paused, or has not - started. - """ - before = self.focus - # Snap to the next multiple of five rather than adding to whatever is - # there: stepping by five from an odd length can never reach a round - # number, so 1 minute becomes 6, 11, 16 and never 25. - step = 5 - target = ((before // step + 1) * step if delta > 0 - else (before - 1) // step * step) - self.focus = max(1, min(120, target)) - change = (self.focus - before) * 60.0 - if change and self.phase == "focus": - if self.running and self.deadline: - self.deadline += change - else: - self.left += change - if self.signed() > 0: - # extended back out of overtime, so let it alert again - self.rang = False - self.nagged = 0 - self.save() - - def tick(self): - """True exactly once, when a phase elapses. - - The phase is not advanced automatically: overrunning a focus block is - worth seeing rather than silently resetting, so the timer keeps - counting upward until `s` moves it on. - """ - if not self.running: - return False - over = self.overtime() - if over <= 0: - return False - if not self.rang: - self.rang = True - self.nagged = 0 - return True - # keep nagging once per minute for as long as it is ignored - minutes = int(over // 60) - if minutes > self.nagged: - self.nagged = minutes - return True - return False - - -def hint_tokens(pomo): - """Key hints for the current state, as atomic tokens for pack_hints. - - Only the pomodoro's own controls are hidden by `?`. The toggle itself and - the panel-level keys always stay: hiding the way back leaves no way back. - """ - tokens = [] - if pomo.enabled and pomo.hints: - tokens += [[(DIM, "[space] "), (TXT, "pause" if pomo.running else "start")], - [(DIM, pomo.next_label())], - [(DIM, "[r]estart")], - [(DIM, "[±]%dmin" % pomo.focus)]] - if pomo.completed: - # nothing to reset at zero, so the hint only appears once it counts - tokens.append([(DIM, "[0]reset %d done" % pomo.completed)]) - tokens.append([(DIM, "[p]off" if pomo.enabled else "[p] pomodoro")]) - tokens.append([(DIM, "↑↓ cities")]) - if pomo.enabled: - # only meaningful while there are pomodoro controls to hide, but shown - # even when they are hidden, so there is always a way back. Names the - # action rather than the state, like [s]tart break / [e]nd break. - tokens.append([(DIM, "[?]%s pomodoro tips" - % ("hide" if pomo.hints else "show"))]) - return tokens - - -def render_big(s, w): - return big(s, w) - - -def offset_str(dt): - off = dt.utcoffset() - total = int(off.total_seconds()) // 60 - sign = "+" if total >= 0 else "-" - total = abs(total) - if total % 60: - return "UTC%s%d:%02d" % (sign, total // 60, total % 60) - return "UTC%s%d" % (sign, total // 60) - - -def hms(seconds): - seconds = max(0, int(seconds)) - return "%02d:%02d:%02d" % (seconds // 3600, seconds % 3600 // 60, seconds % 60) - - -def at(now, day, hour): - """Wall-clock `hour` on `day`, in the same timezone as `now`.""" - return datetime.datetime.combine(day, datetime.time(hour, 0), tzinfo=now.tzinfo) - - -def is_office(dt): - return dt.weekday() < 5 and WORK_START_H <= dt.hour < WORK_END_H - - -def next_open(now): - day = now.date() - cand = at(now, day, WORK_START_H) - while cand <= now or cand.weekday() >= 5: - day += datetime.timedelta(days=1) - cand = at(now, day, WORK_START_H) - return cand - - -def prev_close(now): - day = now.date() - cand = at(now, day, WORK_END_H) - while cand > now or cand.weekday() >= 5: - day -= datetime.timedelta(days=1) - cand = at(now, day, WORK_END_H) - return cand - - -def office_countdown(now): - """Office hours are Mon-Fri 09:00-18:00 local.""" - if is_office(now): - start = at(now, now.date(), WORK_START_H) - end = at(now, now.date(), WORK_END_H) - span = (end - start).total_seconds() - return ("End of Office Hour", hms((end - now).total_seconds()), - (now - start).total_seconds() / span) - opens = next_open(now) - closed = prev_close(now) - span = (opens - closed).total_seconds() - return ("Start of Office Hour", hms((opens - now).total_seconds()), - (now - closed).total_seconds() / span if span > 0 else 0.0) - - -def countdowns(now): - """Real countdowns for the current local day. Returns rows of - (label, text, elapsed_fraction, colour), stacked top to bottom.""" - midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) - day_end = midnight + datetime.timedelta(days=1) - hour_start = now.replace(minute=0, second=0, microsecond=0) - hour_end = hour_start + datetime.timedelta(hours=1) - label, text, frac = office_countdown(now) - - return [ - ("Next Hour", hms((hour_end - now).total_seconds()), - (now - hour_start).total_seconds() / 3600.0, HOUR), - (label, text, frac, EVE), - ("End of Day", hms((day_end - now).total_seconds()), - (now - midnight).total_seconds() / 86400.0, PURPLE), - ] - - -def phase(dt): - """Colour + glyph for what people there are plausibly doing.""" - hour = dt.hour + dt.minute / 60.0 - weekend = dt.weekday() >= 5 - if hour < 6.5 or hour >= 22: - return NIGHT, "☾" - if weekend: - return WEEKEND, "☀" - if 9 <= hour < 18: - return WORK, "☀" - if hour >= 18: - return EVE, "☾" - return EVE, "☀" - - -def main(): - maybe_help(__doc__) - setup() - keyboard = Keyboard() - pomo = Pomodoro() - scroll = 0 - flash_at = 0.0 - zones = [] - for name, key in CITIES: - try: - zones.append((name, ZoneInfo(key))) - except Exception: - continue - local_key = time.tzname[0] - while True: - for key in keyboard.poll(): - if key in ("q", "Q"): - keyboard.restore() - raise SystemExit(0) - if key == "up": - scroll -= 1 - elif key == "down": - scroll += 1 - elif key == "pgup": - scroll -= 8 - elif key == "pgdn": - scroll += 8 - elif key == "home": - scroll = 0 - elif key == "end": - scroll = 10 ** 6 # clamped to the end below - elif key in ("?", "h"): - pomo.hints = not pomo.hints - pomo.save() - elif key == "p": - pomo.toggle() - elif not pomo.enabled: - continue - elif key == " ": - pomo.pause() - elif key == "r": - pomo.restart() - elif key in ("s", "b", "e"): - # one action, three mnemonics: skip / break / end - pomo.advance() - elif key in ("0", "c"): - pomo.reset_count() - elif key in ("+", "="): - pomo.adjust(5) - elif key == "-": - pomo.adjust(-5) - pomo.roll_day() - if pomo.tick(): - over = pomo.overtime() - alert("%s %s" % (PHASE_LABEL[pomo.phase], - "finished" if over < 60 - else "running %dm over" % (over // 60)), - pomo.bell, pomo.notify, - "done" if over < 60 else "request") - if bool(_CFG["pomodoro_flash"]): - flash_at = time.time() - - w, h = size() - stamp = datetime.datetime.now(TZ) if TZ else datetime.datetime.now().astimezone() - - rows = [title("clocks", w)] - rows.append(DIM + " ── SERVER TIME ──") - for i, ln in enumerate(render_big(stamp.strftime("%H:%M:%S"), w - 2)): - rows.append(" " + (C1 if i < 3 else C2) + ln) - rows.append("") - rows.append(seg([(DIM, " "), (TXT, stamp.strftime("%Y-%m-%d %A").upper()), - (DIM, " " + offset_str(stamp))], w - 1)) - rows.append("") - rows.append(DIM + " ── COUNTDOWN ──") - if pomo.enabled: - over = pomo.overtime() - col = FOCUS if pomo.phase == "focus" else BREAK - if not pomo.running: - col = PAUSED - rows.append(seg([(col, " " + pad("Pomodoro · " + PHASE_LABEL[pomo.phase], - 23)), - (OVER if over else TXT, - ("+" + hms(over)) if over else hms(pomo.remaining())), - (PAUSED, " paused" if not pomo.running else ""), - (OVER if over else DIM, - " OVER" if over else ""), - (DIM, " %d done" % pomo.completed)], w - 1)) - n = max(4, w - 3) - if over: - # the bar rescales to duration+overtime, so the red share grows - # the longer the phase is ignored - total = pomo.duration() + over - base = max(1, int(round(n * pomo.duration() / total))) - rows.append(" " + col + "█" * base + OVER + "█" * (n - base)) - else: - done = 1.0 - (pomo.remaining() / pomo.duration() - if pomo.duration() else 0) - rows.append(" " + col + bar(done, n)) - for label, text, frac, col in countdowns(stamp): - rows.append(seg([(DIM, " " + pad(label, 21)), (TXT, text)], w - 1)) - rows.append(" " + col + bar(frac, max(4, w - 3))) - - rows.append("") - - # sort west -> east by current UTC offset - now_utc = datetime.datetime.now(datetime.timezone.utc) - entries = [] - for name, tz in zones: - entries.append((now_utc.astimezone(tz), name)) - entries.sort(key=lambda e: (e[0].utcoffset(), e[1])) - - # The clock, countdowns and footer stay pinned; only this list scrolls. - room = max(1, h - len(rows) - 1 - - len(pack_hints(hint_tokens(pomo), w - 2))) - scroll = max(0, min(scroll, max(0, len(entries) - room))) - window = entries[scroll:scroll + room] - more = len(entries) > room - rows.append(DIM + " ── WORLD CLOCK ──" + - (" %d-%d of %d ↑↓" % (scroll + 1, scroll + len(window), - len(entries)) if more else "")) - namew = max(9, min(15, w - 26)) - for dt, name in window: - if len(rows) >= h - 1: - break - col, glyph = phase(dt) - same_zone = dt.strftime("%Z") == stamp.strftime("%Z") - daydiff = (dt.date() - stamp.date()).days - tag = "" - if daydiff > 0: - tag = " +1d" - elif daydiff < 0: - tag = " -1d" - rows.append(seg([(col, " " + glyph + " "), - (HERE if same_zone else TXT, pad(name, namew)), - (col, dt.strftime(" %H:%M")), - (DIM, dt.strftime(" %a")), - (DIM, " " + pad(offset_str(dt), 8)), - (EVE, tag)], w - 1)) - footer = [" " + line for line in pack_hints(hint_tokens(pomo), w - 2)] - rows = rows[:h - len(footer)] - while len(rows) < h - len(footer): - rows.append("") - rows.extend(footer) - if flash_window(flash_at, int(_CFG["pomodoro_flash_count"]), - float(_CFG["pomodoro_flash_gap"])): - draw(flash_frame(rows, w, h), w, h) - else: - draw(rows, w, h) - time.sleep(0.15) - - -main() diff --git a/common.py b/common.py deleted file mode 100644 index 111a9c9..0000000 --- a/common.py +++ /dev/null @@ -1,583 +0,0 @@ -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Shared drawing and input helpers for the terminal widgets.""" -import atexit -import base64 -import json -import math -import os -import re -import select -import shutil -import signal -import sys -import termios -import time -import tty - -HIDE = "\x1b[?25l" -SHOW = "\x1b[?25h" -HOME = "\x1b[H" -CLEAR = "\x1b[2J" -EL = "\x1b[K" -RST = "\x1b[0m" - - -CONFIG_NAME = "config.json" - - -def config_paths(): - """Where settings are looked for, in order of preference.""" - env = os.environ.get("TERMINAL_TOYS_CONFIG") - xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") - here = os.path.dirname(os.path.abspath(__file__)) - return [p for p in (env, - os.path.join(xdg, "terminal-toys", CONFIG_NAME), - os.path.join(here, CONFIG_NAME)) if p] - - -def missing(*programs): - """Which of these required commands are not on PATH.""" - return [p for p in programs if not shutil.which(p)] - - -def cannot_start(name, needed, why, install=""): - """Draw a widget that cannot run, and wait, rather than exiting. - - A widget that exits on a missing dependency is a pane that vanishes the - moment you look at it, taking its explanation with it - and in a tiled - wall, or launched from the menu, there is nowhere for a line on stderr - to go. So it draws the reason at the size it was given and holds there, - answering q like everything else. Nothing is polled and nothing is - retried: the answer will not change while it sits there. - """ - BAD = rgb(255, 100, 110) - DIM = rgb(127, 147, 172) - TXT = rgb(225, 235, 245) - setup() - keyboard = Keyboard() - while True: - for key in keyboard.poll(): - if key in ("q", "Q"): - raise SystemExit(0) - w, h = size() - rows = [title(name, w, BAD), ""] - rows.append(seg([(BAD, " cannot start · "), - (TXT, "needs %s" % ", ".join(needed))], w - 1)) - rows.append("") - for line in why: - rows.append(seg([(DIM, " " + line)], w - 1)) - if install: - rows.append("") - rows.append(seg([(DIM, " try: "), (TXT, install)], w - 1)) - while len(rows) < h - 2: - rows.append("") - rows.append(" " + pack_hints([[(DIM, "[q]uit")]], w - 2)[0]) - draw(rows, w, h) - time.sleep(0.2) - - -def config_token_warning(): - """Warn when a config file holding a token is readable by others. - - Any widget that takes a token writes it into this file, so the check - belongs beside the loader rather than in whichever widget happened to - need it first. - """ - for path in config_paths(): - if not path or not os.path.exists(path): - continue - try: - mode = os.stat(path).st_mode & 0o077 - except OSError: - return None - return "config.json is readable by others; chmod 600 it" if mode else None - return None - - -def load_config(section, defaults): - """Settings for `section`, overlaid on `defaults`. - - Keeps personal data — hostnames, targets, city lists — out of the source - tree and therefore out of a public repository. The first readable file in - `config_paths()` wins; unknown keys are ignored, and a malformed file falls - back to the defaults rather than crashing a running panel. - """ - merged = dict(defaults) - for path in config_paths(): - try: - with open(path) as f: - data = json.load(f) - except (OSError, ValueError): - continue - chunk = data.get(section) - if isinstance(chunk, dict): - for key, value in chunk.items(): - if key in merged: - merged[key] = value - return merged - return merged - - -def maybe_help(doc): - """Print the tool's docstring and exit if -h/--help was passed.""" - if any(a in ("-h", "--help") for a in sys.argv[1:]): - print((doc or "").strip()) - raise SystemExit(0) - - -def size(): - try: - c = os.get_terminal_size() - return max(8, c.columns), max(4, c.lines) - except OSError: - return 80, 24 - - -def rgb(r, g, b): - return "\x1b[38;2;%d;%d;%dm" % (r, g, b) - - -def bg(r, g, b): - return "\x1b[48;2;%d;%d;%dm" % (r, g, b) - - -def out(s): - sys.stdout.write(s) - - -def flush(): - try: - sys.stdout.flush() - except BrokenPipeError: - raise SystemExit(0) - - -def _bye(*_a): - out(SHOW + RST + CLEAR + HOME) - flush() - raise SystemExit(0) - - -def setup(): - signal.signal(signal.SIGINT, _bye) - signal.signal(signal.SIGTERM, _bye) - out(HIDE + CLEAR + HOME) - flush() - - -def draw(rows, w, h): - """Paint `rows` (list of pre-colored strings) from the top-left.""" - buf = [HOME] - for i in range(h): - line = rows[i] if i < len(rows) else "" - buf.append(line + RST + EL) - if i != h - 1: - buf.append("\r\n") - out("".join(buf)) - flush() - - -def pad(s, n): - """Truncate/pad a *plain* (uncolored) string to n cells.""" - if len(s) > n: - return s[:n] - return s + " " * (n - len(s)) - - -def bar(frac, n, on="█", off="░"): - frac = 0.0 if frac < 0 else (1.0 if frac > 1 else frac) - k = int(round(frac * n)) - return on * k + off * (n - k) - - -def seg(parts, width): - """Join (color, text) segments, hard-clipped to `width` printable cells.""" - out = [] - n = 0 - for color, text in parts: - if n >= width: - break - room = width - n - if len(text) > room: - text = text[:room] - out.append(color + text) - n += len(text) - return "".join(out) - - -BIG_DIGITS = { - "0": ["███", "█ █", "█ █", "█ █", "███"], - "1": [" █", " █", " █", " █", " █"], - "2": ["███", " █", "███", "█ ", "███"], - "3": ["███", " █", "███", " █", "███"], - "4": ["█ █", "█ █", "███", " █", " █"], - "5": ["███", "█ ", "███", " █", "███"], - "6": ["███", "█ ", "███", "█ █", "███"], - "7": ["███", " █", " █", " █", " █"], - "8": ["███", "█ █", "███", "█ █", "███"], - "9": ["███", "█ █", "███", " █", "███"], - ":": [" ", " █ ", " ", " █ ", " "], - "%": ["█ █", " █", " █ ", "█ ", "█ █"], - ".": [" ", " ", " ", " ", " █ "], - " ": [" ", " ", " ", " ", " "], - "-": [" ", " ", "███", " ", " "], -} - - -def big(text, width=None): - """Render text as five rows of block digits.""" - rows = ["", "", "", "", ""] - for ch in text: - glyph = BIG_DIGITS.get(ch, BIG_DIGITS[" "]) - for i in range(5): - rows[i] += glyph[i] + " " - return [r[:width] if width else r for r in rows] - - -# Braille packs 2x4 sub-pixels into one cell, so a chart drawn with it has -# eight times the resolution of block characters. Dot bit per (column, row): -BRAILLE_DOTS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80)) - - -def braille_plot(values, width, height, lo=None, hi=None): - """A continuous line chart in braille. - - Consecutive samples are joined rather than plotted as isolated dots - - without that the line reads as scattered specks wherever it moves quickly. - Returns `height` strings of `width` cells. - """ - if not values: - return [""] * height - lo = min(values) if lo is None else lo - hi = max(values) if hi is None else hi - span = (hi - lo) or 1.0 - px_w, px_h = width * 2, height * 4 - cells = [[0] * width for _ in range(height)] - - def row_of(v): - return max(0, min(px_h - 1, int(round((1 - (v - lo) / span) * (px_h - 1))))) - - prev = None - for px in range(px_w): - v = values[min(len(values) - 1, int(px * len(values) / float(px_w)))] - y = row_of(v) - span_y = (y, y) if prev is None else (min(prev, y), max(prev, y)) - for py in range(span_y[0], span_y[1] + 1): - cells[py // 4][px // 2] |= BRAILLE_DOTS[px % 2][py % 4] - prev = y - return ["".join(chr(0x2800 + c) for c in row) for row in cells] - - -def stacked_bar(parts, width): - """Proportions as one bar: [(fraction, colour), ...] -> coloured segments. - - A bar beats a pie in a character grid - no aliasing, and the eye compares - lengths far better than angles. - """ - out, used = [], 0 - for i, (frac, colour) in enumerate(parts): - n = width - used if i == len(parts) - 1 else int(round(frac * width)) - n = max(0, min(n, width - used)) - if n: - out.append((colour, "█" * n)) - used += n - return out - - -def meter(frac, n, on="█", off="░"): - """A gauge bar. Rectangular blocks, not parallelograms: ▰▱ are literally - tilted and read as slanted next to everything else on a character grid.""" - frac = 0.0 if frac < 0 else (1.0 if frac > 1 else frac) - k = int(round(frac * n)) - return on * k + off * (n - k) - - -def spread(count, room): - """Cell widths for `count` bars that fill `room` columns exactly. - - The remainder goes to the leftmost bars rather than being dropped on the - floor by integer division: stopping short of the right edge leaves no way - to tell a finished chart from a truncated one. Twenty-eight days across - fifty-nine columns is two cells each and three columns wasted, which - reads as a chart that gave up. - """ - if count <= 0: - return [] - if count >= room: - # One cell each and the caller decides what to drop: silently - # returning fewer widths than bars would lose data without saying so. - return [1] * count - slot, extra = divmod(room, count) - return [slot + (1 if i < extra else 0) for i in range(count)] - - -def vbars(columns, height, hi=None): - """Vertical bar chart. `columns` is [(value, colour), ...]. - - Each cell resolves an eighth of a row via the partial-block glyphs, so a - five-row chart has forty levels rather than five. Pass `hi` to fix the - full-scale value, so two charts can share a scale and stay comparable. - """ - steps = " ▁▂▃▄▅▆▇█" - hi = hi or max((v for v, _ in columns), default=0) or 1 - rows = [] - for r in range(height): - top = hi * (height - r) / float(height) - bottom = hi * (height - r - 1) / float(height) - line = [] - for value, colour in columns: - if value >= top: - ch = "█" - elif value <= bottom: - ch = " " - else: - ch = steps[max(1, int((value - bottom) / (top - bottom) * 8))] - line.append((colour, ch)) - rows.append(line) - return rows - - -def vbars_down(columns, height, hi=None): - """Bar chart hanging downward from a baseline above it. - - Paired with `vbars` and a shared `hi`, this makes a diverging chart: one - series growing up, another down, one column per day. - - The partial-block glyphs are all bottom-anchored, so a downward bar cannot - resolve an eighth of a cell the way `vbars` does - only `▀` exists as a - top-anchored partial. Half a cell is ample once peaks are scaled, and the - alternative (inverting foreground and background) needs the terminal's - background painted, which these widgets deliberately never do. - """ - hi = hi or max((v for v, _ in columns), default=0) or 1 - rows = [] - for r in range(height): - full = hi * (r + 1) / float(height) # value that fills this row - empty = hi * r / float(height) # value at which it starts - line = [] - for value, colour in columns: - if value >= full: - ch = "█" - elif value <= empty: - ch = " " - else: - ch = "▀" if (value - empty) / (full - empty) >= 0.5 else " " - line.append((colour, ch)) - rows.append(line) - return rows - - -def dance(width, tick, phase=0.0): - """Column heights in 0..1 bouncing like a level meter, for pending data. - - Two sine waves of different periods per column, so neighbours move - together enough to read as one instrument but never march in lockstep. - Deterministic in `tick`, so every frame is reproducible and no random - source is needed. - """ - out = [] - for i in range(width): - a = math.sin(tick * 0.55 + i * 0.85 + phase) - b = math.sin(tick * 0.31 + i * 0.41 + phase * 1.7) - out.append(min(1.0, max(0.08, 0.5 + 0.33 * a + 0.17 * b))) - return out - - -def mix(c1, c2, t): - """Blend two (r, g, b) tuples, for fading a placeholder into real data.""" - t = 0.0 if t < 0 else (1.0 if t > 1 else t) - return rgb(*[int(round(c1[i] + (c2[i] - c1[i]) * t)) for i in range(3)]) - - -def skeleton(width, tick, span=7): - """A placeholder bar with a highlight sweeping across it. - - For values that are being refetched: showing the previous number while a - new one is in flight states something false, and blanking the row makes - the layout jump. A shimmering grey bar says "pending" without either. - """ - period = width + span * 2 - centre = (tick % period) - span - out, last, run = [], None, [] - for i in range(width): - near = max(0.0, 1.0 - abs(i - centre) / float(span)) - level = int(58 + near * 118) - colour = rgb(level, level, level + 8) - if colour != last: - if run: - out.append((last, "".join(run))) - last, run = colour, [] - run.append("█") - if run: - out.append((last, "".join(run))) - return out - - -def pack_hints(hints, width, sep=" "): - """Lay key hints across as many lines as they need. - - Each hint is a list of (colour, text) segments and is kept whole: footers - are the one place a truncated line is actively harmful, since a hint cut to - "[±]25" teaches the wrong key. Returns the rendered rows, so a caller can - reserve exactly that many at the bottom. - """ - rows, parts, used = [], [], 0 - for hint in hints: - length = sum(len(text) for _, text in hint) - gap = len(sep) if parts else 0 - if parts and used + gap + length > width: - rows.append("".join(parts)) - parts, used, gap = [], 0, 0 - if gap: - parts.append(sep) - for color, text in hint: - parts.append(color + text) - used += gap + length - if parts: - rows.append("".join(parts)) - return rows or [""] - - -def heat(frac): - """Green -> amber -> red gradient.""" - frac = 0.0 if frac < 0 else (1.0 if frac > 1 else frac) - if frac < 0.5: - t = frac / 0.5 - return rgb(int(40 + 200 * t), 255, int(120 - 100 * t)) - t = (frac - 0.5) / 0.5 - return rgb(255, int(240 - 200 * t), int(20 + 10 * t)) - - -def title(text, w, color=None, accent="│"): - color = color or rgb(0, 255, 170) - t = " " + text.upper() + " " - left = "╺━" - fill = "━" * max(0, w - len(t) - len(left) - 1) - return color + left + RST + rgb(220, 255, 240) + t + RST + color + fill + "╸" + RST - - -def now(): - return time.strftime("%H:%M:%S") - - -KEY_SEQUENCES = { - "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left", - "\x1bOA": "up", "\x1bOB": "down", "\x1bOC": "right", "\x1bOD": "left", - "\x1b[5~": "pgup", "\x1b[6~": "pgdn", - "\x1b[H": "home", "\x1b[F": "end", "\x1b[1~": "home", "\x1b[4~": "end", - "\r": "enter", "\n": "enter", "\x7f": "backspace", "\t": "tab", -} -CSI_RE = re.compile(r"\x1b(\[[0-9;]*[A-Za-z~]|O[A-Za-z])") - - -class Keyboard(object): - """Non-blocking key input, decoding arrows and navigation sequences. - - Returns names ("up", "pgdn", "enter", "esc") for special keys and the bare - character otherwise. No-ops when stdin is not a tty, so piped and cron runs - are unaffected. Restores termios on exit. - """ - - def __init__(self): - self.fd = None - self.saved = None - self.buf = "" - self._lone_esc = False - if sys.stdin.isatty(): - try: - self.fd = sys.stdin.fileno() - self.saved = termios.tcgetattr(self.fd) - tty.setcbreak(self.fd) - atexit.register(self.restore) - except (termios.error, ValueError): - self.fd = None - - def restore(self): - if self.fd is not None and self.saved is not None: - try: - termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved) - except (termios.error, ValueError): - pass - - def poll(self): - keys = [] - if self.fd is None: - return keys - while select.select([self.fd], [], [], 0)[0]: - try: - chunk = os.read(self.fd, 64) - except OSError: - break - if not chunk: - break - self.buf += chunk.decode("utf-8", "replace") - - while self.buf: - if self.buf[0] == "\x1b": - match = None - for seq, name in KEY_SEQUENCES.items(): - if len(seq) > 1 and self.buf.startswith(seq): - if match is None or len(seq) > len(match[0]): - match = (seq, name) - if match: - self.buf = self.buf[len(match[0]):] - keys.append(match[1]) - continue - m = CSI_RE.match(self.buf) - if m: # a sequence we don't map; drop it - self.buf = self.buf[m.end():] - continue - if self.buf == "\x1b": - # bare ESC, or the start of a sequence still arriving. Only - # treat it as Escape once a second poll finds nothing more. - if self._lone_esc: - self.buf = "" - self._lone_esc = False - keys.append("esc") - else: - self._lone_esc = True - break - self.buf = self.buf[1:] # malformed; discard the ESC - continue - ch, self.buf = self.buf[0], self.buf[1:] - keys.append(KEY_SEQUENCES.get(ch, ch)) - if self.buf != "\x1b": - self._lone_esc = False - return keys - - -def cycle(seq, current): - try: - return seq[(seq.index(current) + 1) % len(seq)] - except ValueError: - return seq[0] - - -def clipboard(text): - """Ask the terminal to put `text` on the system clipboard, via OSC 52. - - The terminal emulator performs the copy, so this reaches the machine you - are sitting at even when the program runs on a remote host over SSH. - Multiplexers must be willing to forward it. Returns False when stdout is - not a terminal, so callers can fall back to showing the text instead. - """ - if not sys.stdout.isatty(): - return False - payload = base64.b64encode(text.encode("utf-8")).decode("ascii") - out("\x1b]52;c;%s\x07" % payload) - flush() - return True diff --git a/config.example.json b/config.example.json index 5d4e88f..75647a6 100644 --- a/config.example.json +++ b/config.example.json @@ -35,7 +35,26 @@ ] ], "work_start_hour": 9, - "work_end_hour": 18 + "work_end_hour": 18, + "_show_hints_comment": "Whether the pomodoro key hints sit under the panel. Visible by default: the hints are how the keys are found in the first place, and starting hidden means a reader has to already know the key that reveals them. [?] toggles it live.", + "show_hints": true, + "_work_days_comment": "Which days count as working days, as names (sun, mon, ...) or numbers. Drives the working-day shading.", + "work_days": ["mon", "tue", "wed", "thu", "fri"], + "pomodoro_enabled": false, + "pomodoro_focus_minutes": 25, + "pomodoro_short_break_minutes": 5, + "pomodoro_long_break_minutes": 15, + "pomodoro_sessions_before_long_break": 4, + "pomodoro_bell": true, + "pomodoro_notify": true, + "pomodoro_flash": true, + "pomodoro_flash_count": 2, + "pomodoro_flash_gap": 1.0, + "pomodoro_flash_rgb": [ + 246, + 248, + 252 + ] }, "deployments": { "_comment": "token: create one at Account Settings -> Tokens. The Vercel CLI's session is not used - it expires within hours. Keep this file chmod 600.", @@ -47,6 +66,8 @@ "projects": [] }, "tailnet": { + "_comment_history": "rate samples kept per peer; the chart is a window on to this", + "history": 180, "refresh": 5 }, "herdr_panes": { @@ -57,7 +78,7 @@ "token": "", "token_env": "GITHUB_TOKEN", "accounts": [], - "window_days": 7, + "window_days": 14, "refresh": 120 }, "linear": { @@ -86,7 +107,14 @@ "_rates_comment": "OPTIONAL override. Anthropic's published list prices ship in the code, dated on screen, so Claude models are priced with no config at all. Set entries here to correct a stale price, to add a provider that publishes none (OpenAI, xAI), or to use your own negotiated rates. US$ per million tokens, keyed by model; longest matching name wins and '*' catches the rest.", "rates": {}, "_plan_cost_comment": "What each subscription costs you per month, keyed by agent, for example claude: 200. Nothing ships here: Anthropic lists Max as 'from $100' because it varies by tier, and no invoice is on this machine. Set it and METERED adds 'the plan saves'.", - "plan_cost": {} + "plan_cost": {}, + "_antigravity_remote_comment": "Antigravity publishes its quota two ways: a language server that runs inside the app, and Google, which serves the same summary on the endpoint the tier already comes from. The local one is preferred and this only decides whether the remote one may be asked when the app is shut. On by default - it is the same host and the same credential the tier request already uses. Turn it off and the quota is absent whenever Antigravity is closed; the tab then says so, and names this key.", + "antigravity_remote": true, + "_antigravity_start_comment": "Whether the widget may start the `agy` CLI to read the quota it serves, when nothing else has one. On by default, and the only setting here that runs another program. It is your own CLI, already installed and signed in; it is started under a pseudo-terminal, killed by pid as soon as the reading is taken, and reaped, so nothing outlives the fetch; and a CLI you started yourself is found by the ordinary local probe long before this runs, so this can never shut one of yours down. Off means the quota lasts an hour past the last time Antigravity ran, which is the token's life.", + "antigravity_start": true, + "_grok_ping_comment": "Grok is the only agent with no live quota: its figures come from the log its own CLI writes, so they move only when you use Grok on this machine. Turn grok_ping on to ask x.ai instead, using the token the CLI leaves in ~/.grok/auth.json. That also runs the Grok CLI once after a session goes quiet, which is what refreshes the token - without it the asking works until the token lapses and then silently stops. Off by default: a widget that reads should not start talking to a vendor, or starting somebody else's program, because it was launched.", + "grok_ping": false, + "grok_ping_minutes": 5 }, "link": { "_comment": "Every established connection into a port this machine listens on. Empty ports means all of them, which is the useful default. No network traffic: the numbers come from the kernel's own accounting via ss.", diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..ac97ef8 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "toys-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +# The terminal is an ioctl and a termios struct away, both of which are in +# libc. serde_json is here for one reason: every widget reads the same +# config file, and hand-rolling a parser thirteen times over would be a bug +# farm for no gain. +libc = "0.2" +serde_json = "1" +# Timezones: the IANA database, so a world clock is right across a DST +# boundary without this having to parse TZif itself. +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +chrono-tz = "0.10" diff --git a/core/build.rs b/core/build.rs new file mode 100644 index 0000000..722d538 --- /dev/null +++ b/core/build.rs @@ -0,0 +1,130 @@ +//! What the binary can say about itself. +//! +//! The version comes from `Cargo.toml`; the commit and the date come from +//! here, because neither is knowable from source alone. A `--version` that +//! cannot say which commit it is has answered nothing useful: the version +//! only changes at a release, and most builds anybody runs are between two. + +use std::process::Command; + +fn main() { + // The commit, short. `git` may not be here at all - a release tarball + // has no `.git` - and that is not a build failure, it is an honest + // "unknown". A build that refuses to happen off a checkout is worse + // than one that admits what it does not know. + let commit = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".into()); + + // Whether that commit had anything uncommitted under it. A sha alone + // says which commit was checked out, not which source was compiled. + let dirty = Command::new("git") + .args(["status", "--porcelain", "--untracked-files=no"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false); + let commit = if dirty { format!("{}-dirty", commit) } else { commit }; + + // SOURCE_DATE_EPOCH first, which is the convention for making a build + // reproducible: with it set, two builds of the same source agree. Then + // the commit's own date, which has the same property and needs nothing + // set. Wall-clock never, because it makes every build differ from every + // other for no reader's benefit. + let date = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|s| s.parse::().ok()) + .map(|secs| { + Command::new("date") + .args(["-u", "-d", &format!("@{}", secs), "+%Y-%m-%d"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".into()) + }) + .or_else(|| { + Command::new("git") + .args(["log", "-1", "--date=format:%Y-%m-%d", "--format=%cd"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(|| "unknown".into()); + + println!("cargo:rustc-env=TOYS_COMMIT={}", commit); + println!("cargo:rustc-env=TOYS_BUILD_DATE={}", date); + + // What has to rerun this script, and what must not. + // + // Two false starts are worth recording, because each looked right. + // + // Watching `.git/HEAD` alone fails twice over: on a branch checkout + // that file holds `ref: refs/heads/` and does not move when a + // commit lands, and in a linked worktree there is no `.git` directory + // at all. So git is asked where these actually live, and the ref HEAD + // names is followed to the file that does move. + // + // Then the watches were removed altogether, on the theory that a + // script emitting no `rerun-if-changed` runs on every build. That rule + // holds only for a script emitting no `rerun-if-*` of any kind, and + // this one emits `rerun-if-env-changed` below - which overrides the + // default outright. The result was a script that ran almost never: a + // clean tree four commits later still stamped the old sha and a + // `-dirty` that was hours stale, and a thirty-second full rebuild did + // not move it. + // + // Running it unconditionally does work, and costs twenty-eight seconds + // on every no-op build, because a rerun relinks all fourteen binaries + // whether or not the stamp changed. `cargo test` is the ritual before + // every commit here, so that is the wrong trade. + // + // So both halves are watched explicitly. The git files cover the + // commit; the source trees cover `-dirty`, and cost nothing, because a + // source edit was going to rebuild those crates anyway. What this + // cannot see is a new untracked file that no crate compiles - the + // stamp stays clean for one build longer than it should, which is the + // one gap left and a smaller lie than either of the above. + let watch_git = |path: &str| { + let resolved = Command::new("git") + .args(["rev-parse", "--git-path", path]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(file) = resolved { + println!("cargo:rerun-if-changed={}", file); + } + }; + watch_git("HEAD"); + watch_git("packed-refs"); + if let Some(head_ref) = Command::new("git") + .args(["symbolic-ref", "-q", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + watch_git(&head_ref); + } + // Cargo walks a directory watch recursively, so these two cover every + // source file in the workspace without naming one. + for dir in ["../core/src", "../widgets/src", "../Cargo.toml", "../Cargo.lock"] { + println!("cargo:rerun-if-changed={}", dir); + } + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..df5d8be --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,1950 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What every widget shares: the terminal, the keyboard, and the drawing. +//! +//! A port of `common.py`, kept deliberately close to it. The widgets are +//! being translated one at a time and the two versions have to sit side by +//! side and agree on screen, so where a choice existed this keeps the +//! Python behaviour rather than the more idiomatic Rust one. + +use std::io::{Read, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::PermissionsExt; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; + +pub const HIDE: &str = "\x1b[?25l"; +pub const SHOW: &str = "\x1b[?25h"; +pub const HOME: &str = "\x1b[H"; +pub const CLEAR: &str = "\x1b[2J"; +pub const EL: &str = "\x1b[K"; +pub const RST: &str = "\x1b[0m"; +pub const NOBG: &str = "\x1b[49m"; + +/// A foreground colour, as a truecolor escape. +pub fn rgb(r: u8, g: u8, b: u8) -> String { + format!("\x1b[38;2;{};{};{}m", r, g, b) +} + +/// A background colour. Needs an explicit reset afterwards, or it bleeds +/// along the rest of the row - the same trap as in the Python. +pub fn bg(r: u8, g: u8, b: u8) -> String { + format!("\x1b[48;2;{};{};{}m", r, g, b) +} + +/// Truncate or pad a plain string to exactly `n` cells. +pub fn pad(s: &str, n: usize) -> String { + let count = s.chars().count(); + if count > n { + s.chars().take(n).collect() + } else { + let mut out = String::from(s); + out.extend(std::iter::repeat(' ').take(n - count)); + out + } +} + +/// Join coloured segments, hard-clipped to `width` printable cells. +/// +/// The colour of each segment is a prefix that costs nothing on screen, so +/// only the text counts toward the width. Every widget's layout arithmetic +/// depends on that, which is why escapes have to live in the colour half of +/// the pair and never in the text. +pub fn seg(parts: &[(&str, String)], width: usize) -> String { + let mut out = String::new(); + let mut n = 0usize; + for (colour, text) in parts { + if n >= width { + break; + } + let room = width - n; + let count = text.chars().count(); + let cut: String = if count > room { + text.chars().take(room).collect() + } else { + text.clone() + }; + out.push_str(colour); + out.push_str(&cut); + n += cut.chars().count(); + } + out +} + +/// The rule across the top of every widget. +pub fn title(text: &str, w: usize, colour: &str) -> String { + let t = format!(" {} ", text.to_uppercase()); + let left = "╺━"; + let used = t.chars().count() + left.chars().count() + 1; + let fill = "━".repeat(w.saturating_sub(used)); + format!( + "{}{}{}{}{}{}{}{}╸{}", + colour, + left, + RST, + rgb(220, 255, 240), + t, + RST, + colour, + fill, + RST + ) +} + +/// The terminal's size, or a sane pair if it will not say. +pub fn size() -> (usize, usize) { + let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; + let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) }; + if ok == 0 && ws.ws_col > 0 && ws.ws_row > 0 { + ( + std::cmp::max(8, ws.ws_col as usize), + std::cmp::max(4, ws.ws_row as usize), + ) + } else { + (80, 24) + } +} + +pub fn out(text: &str) { + let mut stdout = std::io::stdout(); + let _ = stdout.write_all(text.as_bytes()); +} + +pub fn flush() { + let _ = std::io::stdout().flush(); +} + +/// Paint `rows` from the top-left, one full frame. +/// +/// Every row is followed by a reset and an erase-to-end, so a short row +/// cannot leave the tail of the previous frame behind it. +pub fn draw(rows: &[String], _w: usize, h: usize) { + let mut buf = String::from(HOME); + for i in 0..h { + let empty = String::new(); + let line = rows.get(i).unwrap_or(&empty); + buf.push_str(line); + buf.push_str(RST); + buf.push_str(EL); + if i + 1 != h { + buf.push_str("\r\n"); + } + } + out(&buf); + flush(); +} + +/// Hide the cursor and clear, and put it all back on the way out. +pub fn setup() { + unsafe { + let handler = handle_signal as *const () as libc::sighandler_t; + libc::signal(libc::SIGINT, handler); + libc::signal(libc::SIGTERM, handler); + } + out(&format!("{}{}{}", HIDE, CLEAR, HOME)); + flush(); +} + +/// The bytes `handle_signal` writes. Built as a constant so the handler +/// never formats, allocates, or takes the stdout lock - any of which can +/// deadlock if the signal arrives while `draw` is already writing. +const SCREEN_RESTORE: &str = concat!("\x1b[?25h", "\x1b[0m", "\x1b[2J", "\x1b[H"); + +/// Saved cbreak settings, written by `Keyboard` and read by the handler. +/// +/// The flag is published after the struct, so a handler that sees it true +/// sees a complete copy. A mutex is not an option: locking one from a +/// signal handler is how the previous version could hang instead of +/// restoring the terminal. +static TERM_FD: AtomicI32 = AtomicI32::new(-1); +static HAS_TERMIOS: AtomicBool = AtomicBool::new(false); +static mut SAVED_IOS: libc::termios = unsafe { std::mem::zeroed() }; + +fn remember_termios(fd: i32, ios: libc::termios) { + unsafe { + SAVED_IOS = ios; + } + TERM_FD.store(fd, Ordering::Release); + HAS_TERMIOS.store(true, Ordering::Release); +} + +fn forget_termios() { + HAS_TERMIOS.store(false, Ordering::Release); +} + +/// Restore the saved termios and the screen, using only async-signal-safe +/// calls, then `_exit`. `process::exit` runs atexit handlers and can +/// deadlock on the same stdout lock `draw` holds; Drop on `Keyboard` never +/// runs either way, so the handler has to give the shell its echo back. +extern "C" fn handle_signal(sig: libc::c_int) { + if HAS_TERMIOS.load(Ordering::Acquire) { + let fd = TERM_FD.load(Ordering::Acquire); + if fd >= 0 { + let ios = unsafe { SAVED_IOS }; + unsafe { + libc::tcsetattr(fd, libc::TCSANOW, &ios); + } + } + } + unsafe { + libc::write( + libc::STDOUT_FILENO, + SCREEN_RESTORE.as_ptr() as *const libc::c_void, + SCREEN_RESTORE.len(), + ); + libc::_exit(128 + sig); + } +} + +/// Put the terminal back the way it was found. +pub fn restore_screen() { + out(&format!("{}{}{}{}", SHOW, RST, CLEAR, HOME)); + flush(); +} + +/// Fit hint groups onto as few lines as possible without splitting one. +/// +/// A hint that gets cut in half teaches a key that does not exist, so the +/// line wraps instead of truncating - the rule the whole repo follows. +pub fn pack_hints(hints: &[Vec<(&str, String)>], width: usize, sep: &str) -> Vec { + let mut lines: Vec = Vec::new(); + let mut current: Vec = Vec::new(); + let mut used = 0usize; + for hint in hints { + let plain: usize = hint.iter().map(|(_, t)| t.chars().count()).sum(); + let extra = if current.is_empty() { + plain + } else { + plain + sep.chars().count() + }; + if !current.is_empty() && used + extra > width { + lines.push(current.join(sep)); + current = Vec::new(); + used = 0; + } + let piece: String = hint + .iter() + .map(|(c, t)| format!("{}{}", c, t)) + .collect::>() + .join(""); + if current.is_empty() { + used = plain; + } else { + used += plain + sep.chars().count(); + } + current.push(piece); + } + if !current.is_empty() { + lines.push(current.join(sep)); + } + lines +} + +/// Where settings are looked for, in order of preference. +/// +/// The Python's third place is "beside the script", which a compiled +/// binary does not have: its own directory is target/release, where nobody +/// would put a config. The working directory stands in for it, since that +/// is the project directory when a widget is started from a pane, and the +/// executable's own directory is kept last for a binary shipped with one +/// beside it. +pub fn config_paths() -> Vec { + let mut found = Vec::new(); + if let Ok(env) = std::env::var("TERMINAL_TOYS_CONFIG") { + if !env.is_empty() { + found.push(std::path::PathBuf::from(env)); + } + } + let xdg = std::env::var("XDG_CONFIG_HOME").ok().filter(|s| !s.is_empty()); + let home = std::env::var("HOME").unwrap_or_default(); + let base = xdg.unwrap_or(format!("{}/.config", home)); + found.push(std::path::PathBuf::from(base).join("terminal-toys/config.json")); + if let Ok(cwd) = std::env::current_dir() { + found.push(cwd.join("config.json")); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + found.push(dir.join("config.json")); + } + } + found +} + +/// One section of the config file, or an empty object. +/// +/// The first readable file wins, and a malformed one falls back to the +/// defaults rather than stopping a running panel - a widget on a wall +/// should not vanish because a comma went missing in a file it shares. +pub fn load_config(section: &str) -> serde_json::Value { + for path in config_paths() { + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(_) => continue, + }; + let parsed: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + if let Some(found) = parsed.get(section) { + return found.clone(); + } + return serde_json::json!({}); + } + serde_json::json!({}) +} + +/// A setting, or the default when it is absent or the wrong shape. +pub fn cfg_f64(cfg: &serde_json::Value, key: &str, fallback: f64) -> f64 { + cfg.get(key).and_then(|v| v.as_f64()).unwrap_or(fallback) +} + +/// A wait that `Duration::from_secs_f64` will accept. +/// +/// A missing or malformed setting falls back. A value that is finite and +/// positive is kept. Anything else - negative, NaN, infinite - used to +/// panic the poller after the first read, which froze the pane on its +/// initial data with no error, the same silence a dead thread leaves. +pub fn poll_secs(value: f64, fallback: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value + } else if fallback.is_finite() && fallback > 0.0 { + fallback + } else { + 1.0 + } +} + +pub fn cfg_usize(cfg: &serde_json::Value, key: &str, fallback: usize) -> usize { + cfg.get(key) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .unwrap_or(fallback) +} + +pub fn cfg_str(cfg: &serde_json::Value, key: &str, fallback: &str) -> String { + cfg.get(key) + .and_then(|v| v.as_str()) + .unwrap_or(fallback) + .to_string() +} + +/// A list of settings, or the default when nobody has given one. +/// +/// A list that is there and empty is an answer, not the absence of one, and +/// it is the one case the earlier version got wrong: it read `[]` as "unset" +/// and handed back the fallback, so `"hosts": []` had latency pinging two +/// addresses the config had just said it did not want. Absent means nobody +/// has said what they want and a default is a kindness; present means they +/// have, and substituting a list of our own is the widget talking about +/// something it was not asked about. +/// +/// Anything that is not an array at all - a string where a list belongs - +/// is not an answer either, so it falls back with the absent case. +pub fn cfg_strings(cfg: &serde_json::Value, key: &str, fallback: &[&str]) -> Vec { + match cfg.get(key).and_then(|v| v.as_array()) { + Some(items) => items + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + None => fallback.iter().map(|s| s.to_string()).collect(), + } +} + +/// Ask the terminal to put `text` on the system clipboard, via OSC 52. +/// +/// The terminal emulator performs the copy, so this reaches the machine you +/// are sitting at even when the program runs on a remote host over SSH. +/// Multiplexers must be willing to forward it. Returns false when stdout is +/// not a terminal, so callers can fall back to showing the text instead. +pub fn clipboard(text: &str) -> bool { + if unsafe { libc::isatty(1) } == 0 { + return false; + } + out(&format!("\x1b]52;c;{}\x07", base64(text.as_bytes()))); + flush(); + true +} + +/// Standard base64, because OSC 52 carries its payload that way. +fn base64(data: &[u8]) -> String { + const SET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let mut block = [0u8; 3]; + block[..chunk.len()].copy_from_slice(chunk); + let packed = u32::from(block[0]) << 16 | u32::from(block[1]) << 8 | u32::from(block[2]); + for i in 0..4 { + // Each output character is six bits; the ones past the end of a + // short chunk are padding rather than zeroes. + if i <= chunk.len() { + out.push(SET[(packed >> (18 - 6 * i) & 0x3f) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +/// Green through amber to red. +/// +/// Used wherever a fraction is a temperature - CPU, memory, how close a +/// number is to a limit - so the same load reads the same colour whichever +/// widget is showing it. +pub fn heat(frac: f64) -> String { + let frac = frac.clamp(0.0, 1.0); + if frac < 0.5 { + let t = frac / 0.5; + rgb((40.0 + 200.0 * t) as u8, 255, (120.0 - 100.0 * t) as u8) + } else { + let t = (frac - 0.5) / 0.5; + rgb(255, (240.0 - 200.0 * t) as u8, (20.0 + 10.0 * t) as u8) + } +} + +/// Draw the reason a widget cannot run, and hold until q. +/// +/// Exiting with a message loses it: a widget lives in a pane that is not +/// being watched at the moment it starts, and a line printed to a shell +/// that then sits at a prompt is indistinguishable from the widget never +/// having been launched. This stays on screen until somebody reads it. +pub fn cannot_start(name: &str, needed: &[String], why: &[&str], install: &str) { + let bad = rgb(255, 100, 110); + let dim = rgb(127, 147, 172); + let txt = rgb(225, 235, 245); + setup(); + let mut keyboard = Keyboard::new(); + loop { + for key in keyboard.poll() { + if key == "q" || key == "Q" { + keyboard.restore(); + restore_screen(); + return; + } + } + let (w, h) = size(); + let mut rows = vec![title(name, w, &bad), String::new()]; + rows.push(seg( + &[ + (bad.as_str(), " cannot start · ".into()), + (txt.as_str(), format!("needs {}", needed.join(", "))), + ], + w - 1, + )); + rows.push(String::new()); + for line in why { + rows.push(seg(&[(dim.as_str(), format!(" {}", line))], w - 1)); + } + if !install.is_empty() { + rows.push(String::new()); + rows.push(seg( + &[ + (dim.as_str(), " try: ".into()), + (txt.as_str(), install.to_string()), + ], + w - 1, + )); + } + while rows.len() < h - 1 { + rows.push(String::new()); + } + rows.push(seg(&[(dim.as_str(), " [q]uit".into())], w - 1)); + draw(&rows, w, h); + std::thread::sleep(std::time::Duration::from_millis(200)); + } +} + +/// Warn when a config file holding a token is readable by others. +/// +/// Any widget that takes a token writes it into this file, so the check +/// belongs beside the loader rather than in whichever widget happened to +/// need it first. +pub fn config_token_warning() -> Option { + use std::os::unix::fs::PermissionsExt; + for path in config_paths() { + if !path.exists() { + continue; + } + let mode = std::fs::metadata(&path).ok()?.permissions().mode() & 0o077; + return if mode != 0 { + Some("config.json is readable by others; chmod 600 it".into()) + } else { + None + }; + } + None +} + +/// One HTTPS GET, returning the body. +/// +/// Through curl rather than an HTTP crate, for the same reason every other +/// source here is a subprocess: this collection reads `ss`, `ping`, +/// `tailscale` and `herdr` the same way, and a TLS stack would be forty +/// dependencies and a megabyte to do what curl already does on every +/// machine these run on. +/// +/// The headers go in on **stdin**, never in the arguments. `/proc// +/// cmdline` is world-readable, so a token on the command line is a token +/// handed to every user on the box for as long as the request lasts - and +/// these widgets exist partly to keep one out of the source tree. +pub fn get(url: &str, headers: &[(&str, &str)], seconds: u64) -> Result { + use std::io::Write; + let mut config = format!( + "--silent\n--show-error\n--fail\n--location\n--max-time {}\n--url {}\n", + seconds, + quoted(url) + ); + for (name, value) in headers { + config.push_str(&format!("--header {}\n", quoted(&format!("{}: {}", name, value)))); + } + let mut child = std::process::Command::new("curl") + .arg("--config") + .arg("-") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + child + .stdin + .take() + .ok_or("curl would not take its configuration")? + .write_all(config.as_bytes()) + .map_err(|e| e.to_string())?; + let out = child.wait_with_output().map_err(|e| e.to_string())?; + if out.status.success() { + return Ok(String::from_utf8_lossy(&out.stdout).to_string()); + } + // curl's own message, which names the status code for --fail. Whatever + // it says, it must not be allowed to carry the header back out. + let said = String::from_utf8_lossy(&out.stderr).trim().to_string(); + Err(if said.is_empty() { + format!("curl exited {}", out.status.code().unwrap_or(-1)) + } else { + said + }) +} + +/// One HTTPS POST of a JSON body, returning the body and its headers. +/// +/// The headers come back because a rate limit is only knowable from them, +/// and a widget that polls an API every two minutes should be able to say +/// how much of its hour it has left. +/// +/// Not `--fail`: a GraphQL endpoint answers 200 with an errors array, and +/// on a 4xx the body is usually the only thing that says what was wrong. +/// The status is read off the dumped headers instead. +pub fn post_json( + url: &str, + headers: &[(&str, &str)], + body: &str, + seconds: u64, +) -> Result<(String, Vec<(String, String)>), String> { + use std::io::Write; + let mut config = format!( + "--silent\n--show-error\n--request POST\n--dump-header -\n\ + --max-time {}\n--url {}\n--data {}\n", + seconds, + quoted(url), + quoted(body) + ); + for (name, value) in headers { + config.push_str(&format!("--header {}\n", quoted(&format!("{}: {}", name, value)))); + } + let mut child = std::process::Command::new("curl") + .arg("--config") + .arg("-") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + child + .stdin + .take() + .ok_or("curl would not take its configuration")? + .write_all(config.as_bytes()) + .map_err(|e| e.to_string())?; + let out = child.wait_with_output().map_err(|e| e.to_string())?; + if !out.status.success() { + let said = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(if said.is_empty() { + format!("curl exited {}", out.status.code().unwrap_or(-1)) + } else { + said + }); + } + let text = String::from_utf8_lossy(&out.stdout).to_string(); + let (head, body) = split_response(&text); + let status = head + .first() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse::().ok()) + .unwrap_or(0); + let found: Vec<(String, String)> = head + .iter() + .skip(1) + .filter_map(|line| line.split_once(':')) + .map(|(k, v)| (k.trim().to_lowercase(), v.trim().to_string())) + .collect(); + if !(200..300).contains(&status) { + return Err(refused(status, &body)); + } + Ok((body, found)) +} + +/// What a refused POST says: the status, and the body that explains it. +/// +/// The status on its own is the half of a refusal that never says what to +/// do about it. Linear and GitHub put the missing scope, the malformed +/// field and the expired token in the body - which is the case the comment +/// on `post_json` keeps the body for - and returning `HTTP 400` alone sent +/// a reader to the API documentation for something the API had already +/// explained. +/// +/// Squeezed onto one line and capped the way every other subprocess +/// complaint here is, so an HTML error page cannot take the whole pane. +fn refused(status: u16, body: &str) -> String { + let said: String = body.split_whitespace().collect::>().join(" "); + let said: String = said.chars().take(200).collect(); + if said.is_empty() { + format!("HTTP {}", status) + } else { + format!("HTTP {}: {}", status, said) + } +} + +/// Split curl's `--dump-header -` output into its last header block and +/// the body under it. +/// +/// The last block, because a redirect or a `100 Continue` leaves earlier +/// ones in front of it, and the one that describes the response is the one +/// nearest the body. +fn split_response(text: &str) -> (Vec, String) { + let mut head: Vec = Vec::new(); + let mut rest = text; + loop { + let mut lines = Vec::new(); + let mut at = 0usize; + let mut ended = false; + for line in rest.split_inclusive('\n') { + at += line.len(); + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + ended = true; + break; + } + lines.push(trimmed.to_string()); + } + if !ended || lines.is_empty() || !lines[0].starts_with("HTTP/") { + break; + } + head = lines; + rest = &rest[at..]; + } + (head, rest.to_string()) +} + +/// A value for curl's config format, which takes double quotes and +/// backslash escapes and would otherwise stop at the first space - or, for +/// a GraphQL query, at the end of its first line. +fn quoted(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' | '\\' => { + out.push('\\'); + out.push(c); + } + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +/// Proportions as one bar: (fraction, colour) pairs to coloured segments. +/// +/// A bar beats a pie in a character grid - no aliasing, and the eye compares +/// lengths far better than angles. The last segment takes whatever rounding +/// left over, so the bar is always exactly its width. +pub fn stacked_bar(parts: &[(f64, String)], width: usize) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut used = 0usize; + for (i, (frac, colour)) in parts.iter().enumerate() { + let n = if i + 1 == parts.len() { + width.saturating_sub(used) + } else { + ((frac * width as f64).round() as usize).min(width.saturating_sub(used)) + }; + if n > 0 { + out.push((colour.clone(), "█".repeat(n))); + used += n; + } + } + out +} + +/// A filled fraction of a fixed-width track. +pub fn meter(frac: f64, n: usize) -> String { + let filled = ((frac.clamp(0.0, 1.0) * n as f64).round() as usize).min(n); + format!("{}{}", "█".repeat(filled), "░".repeat(n - filled)) +} + +const EIGHTHS: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +/// Where a window of `room` rows has to start to keep `row` in view. +/// +/// Every list here that is longer than its pane is drawn whole and shown +/// through a window, and they all move it the same way: linear's board and +/// its detail screens, herdr-panes' three sections. It is one function so +/// that a test can break it, which a copy inlined in each could not have. +pub fn follow(at: usize, row: usize, room: usize) -> usize { + if row < at { + row + } else if row + 1 > at + room { + row + 1 - room + } else { + at + } +} + + +/// Vertical bar chart, one column per value. +/// +/// Each cell resolves an eighth of a row through the partial-block glyphs, +/// so a five-row chart has forty levels rather than five. `hi` fixes the +/// full-scale value so two charts can share a scale and stay comparable. +pub fn vbars(columns: &[(f64, String)], height: usize, hi: f64) -> Vec> { + let hi = if hi > 0.0 { + hi + } else { + columns.iter().map(|(v, _)| *v).fold(0.0, f64::max).max(1.0) + }; + (0..height) + .map(|r| { + let top = hi * (height - r) as f64 / height as f64; + let bottom = hi * (height - r - 1) as f64 / height as f64; + columns + .iter() + .map(|(value, colour)| { + let ch = if *value >= top { + '█' + } else if *value <= bottom { + ' ' + } else { + let step = ((value - bottom) / (top - bottom) * 8.0) as usize; + EIGHTHS[step.clamp(1, 8)] + }; + (colour.clone(), ch.to_string()) + }) + .collect() + }) + .collect() +} + +/// Bar chart hanging downward from a baseline above it. +/// +/// Paired with `vbars` and a shared `hi`, this makes a diverging chart: one +/// series growing up, another down, one column per day. +/// +/// The partial-block glyphs are all bottom-anchored, so a downward bar +/// cannot resolve an eighth of a cell the way `vbars` does - only `▀` +/// exists as a top-anchored partial. Half a cell is ample once peaks are +/// scaled, and the alternative needs the terminal's background painted, +/// which these widgets deliberately never do. +pub fn vbars_down(columns: &[(f64, String)], height: usize, hi: f64) -> Vec> { + let hi = if hi > 0.0 { + hi + } else { + columns.iter().map(|(v, _)| *v).fold(0.0, f64::max).max(1.0) + }; + (0..height) + .map(|r| { + let full = hi * (r + 1) as f64 / height as f64; + let empty = hi * r as f64 / height as f64; + columns + .iter() + .map(|(value, colour)| { + let ch = if *value >= full { + '█' + } else if *value <= empty { + ' ' + } else if (value - empty) / (full - empty) >= 0.5 { + '▀' + } else { + ' ' + }; + (colour.clone(), ch.to_string()) + }) + .collect() + }) + .collect() +} + +/// Column heights in 0..1 bouncing like a level meter, for pending data. +/// +/// Two sine waves of different periods per column, so neighbours move +/// together enough to read as one instrument but never march in lockstep. +/// Deterministic in `tick`, so every frame is reproducible and no random +/// source is needed. +pub fn dance(width: usize, tick: usize, phase: f64) -> Vec { + (0..width) + .map(|i| { + let t = tick as f64; + let i = i as f64; + let a = (t * 0.55 + i * 0.85 + phase).sin(); + let b = (t * 0.31 + i * 0.41 + phase * 1.7).sin(); + (0.5 + 0.33 * a + 0.17 * b).clamp(0.08, 1.0) + }) + .collect() +} + +/// Blend two colours, for fading a placeholder into real data. +pub fn mix(a: (u8, u8, u8), b: (u8, u8, u8), t: f64) -> String { + let t = t.clamp(0.0, 1.0); + let step = |x: u8, y: u8| (x as f64 + (y as f64 - x as f64) * t).round() as u8; + rgb(step(a.0, b.0), step(a.1, b.1), step(a.2, b.2)) +} + +/// The next entry after `current`, wrapping; for a key that cycles. +pub fn cycle(choices: &[T], current: T) -> T { + // A value that is not one of the choices starts from the first, as + // common.py's ValueError branch does. unwrap_or(0) then +1 started from + // the second instead, silently skipping a choice whenever the current + // setting came from a config file or an argument rather than from a + // previous press of the key. + match choices.iter().position(|c| *c == current) { + Some(at) => choices[(at + 1) % choices.len()], + None => choices[0], + } +} + +/// Where `tab` goes next, on the rule every widget with focusable sections +/// follows. +/// +/// `lens` is how long each section is right now. Focus moves to the next +/// section after the current one and, from the last, off the end to `None` - +/// so there is a way to put the cursor away, which a ring that only cycles +/// between the sections does not give you. +/// +/// Empty sections are stepped over. A section with no rows is not a place +/// you can be: focusing one leaves the arrows moving an index nothing is +/// drawn from and a footer offering "select" over nothing, which reads as a +/// key that has stopped working. From `None`, this is also how you find the +/// first section worth entering. +pub fn next_section(focus: Option, lens: &[usize]) -> Option { + let from = focus.map_or(0, |at| at + 1); + (from..lens.len()).find(|&at| lens[at] > 0) +} + +/// One step of the cursor when a section has the focus. +/// +/// Returns the section and row it lands on, or `None` when it leaves the +/// sections entirely - which happens at exactly two places: up from the first +/// row of the first section, and down from the last row of the last one. +/// +/// Everywhere else the sections read as one continuous list. Walking off the +/// bottom of a section steps into the top of the next, and walking off the +/// top steps into the *bottom* of the one above - the row you were about to +/// reach if the two had been a single list. Stepping out to nothing in the +/// middle of a screen made the arrows stop for a reason the screen could not +/// show, and left `tab` as the only way to reach a section you were sitting +/// right next to. +/// +/// Empty sections are stepped over in both directions, for the reason +/// `next_section` steps over them. A section that empties under the cursor +/// escapes rather than trapping it. +pub fn step_across_sections( + focus: usize, + at: usize, + lens: &[usize], + down: bool, +) -> Option<(usize, usize)> { + let len = lens.get(focus).copied().unwrap_or(0); + if down { + if at + 1 < len { + return Some((focus, at + 1)); + } + let next = (focus + 1..lens.len()).find(|&i| lens[i] > 0)?; + Some((next, 0)) + } else { + if at > 0 && at < len { + return Some((focus, at - 1)); + } + let above = (0..focus.min(lens.len())).rev().find(|&i| lens[i] > 0)?; + Some((above, lens[above] - 1)) + } +} + +/// A placeholder bar with a highlight sweeping across it. +/// +/// For values that are being refetched: showing the previous number while a +/// new one is in flight states something false, and blanking the row makes +/// the layout jump. A shimmering grey bar says "pending" without either. +pub fn skeleton(width: usize, tick: usize, span: usize) -> Vec<(String, String)> { + let period = width + span * 2; + let centre = (tick % period) as f64 - span as f64; + let mut out: Vec<(String, String)> = Vec::new(); + for i in 0..width { + let near = (1.0 - (i as f64 - centre).abs() / span as f64).max(0.0); + let level = (58.0 + near * 118.0) as u8; + let colour = rgb(level, level, level.saturating_add(8)); + match out.last_mut() { + Some((had, run)) if *had == colour => run.push('█'), + _ => out.push((colour, "█".to_string())), + } + } + out +} + +/// Cell widths for `count` bars that fill `room` columns exactly. +/// +/// The remainder goes to the leftmost bars rather than being dropped on the +/// floor by integer division: stopping short of the right edge leaves no way +/// to tell a finished chart from a truncated one. Twenty-eight days across +/// fifty-nine columns is two cells each and three columns wasted, which +/// reads as a chart that gave up. +pub fn spread(count: usize, room: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + if count >= room { + // One cell each and the caller decides what to drop: silently + // returning fewer widths than bars would lose data without saying so. + return vec![1; count]; + } + let (slot, extra) = (room / count, room % count); + (0..count) + .map(|i| slot + usize::from(i < extra)) + .collect() +} + +/// Which of these required commands are not on PATH. +pub fn missing(programs: &[&str]) -> Vec { + let path = std::env::var("PATH").unwrap_or_default(); + programs + .iter() + .filter(|p| { + !path.split(':').any(|dir| { + let candidate = std::path::Path::new(dir).join(p); + // `is_file` is not enough: shutil.which, which the Python + // uses, also requires the executable bit. A readable but + // non-executable file of the right name on PATH would + // otherwise count as the tool being present, and the widget + // would start and then fail on every call instead of saying + // up front what it needs. + candidate.is_file() + && std::fs::metadata(&candidate) + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + }) + }) + .map(|p| p.to_string()) + .collect() +} + +/// Non-blocking key input, decoding the sequences arrows arrive as. +/// +/// Returns names for special keys and the bare character otherwise, and +/// restores the terminal's settings when dropped - including when the +/// widget exits by panicking. +pub struct Keyboard { + fd: i32, + saved: Option, + buf: Vec, + /// What has arrived but not yet decoded. Kept between polls, because a + /// sequence can be torn across two reads on a slow link and half an + /// arrow key is not an Escape. + pending: String, + /// A bare ESC is held for one poll before it counts as Escape: it is + /// indistinguishable from the start of a sequence still arriving. + lone_esc: bool, +} + +impl Keyboard { + pub fn new() -> Keyboard { + let fd = std::io::stdin().as_raw_fd(); + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + let is_tty = unsafe { libc::isatty(fd) } == 1; + let saved = if is_tty && unsafe { libc::tcgetattr(fd, &mut saved) } == 0 { + let mut raw = saved; + // cbreak, not full raw: characters arrive unbuffered and + // unechoed, while the terminal keeps translating signals. + raw.c_lflag &= !(libc::ICANON | libc::ECHO); + raw.c_cc[libc::VMIN] = 0; + raw.c_cc[libc::VTIME] = 0; + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) }; + remember_termios(fd, saved); + Some(saved) + } else { + None + }; + Keyboard { + pending: String::new(), + lone_esc: false, + fd, + saved, + buf: Vec::new(), + } + } + + pub fn restore(&mut self) { + if let Some(saved) = self.saved.take() { + forget_termios(); + unsafe { libc::tcsetattr(self.fd, libc::TCSADRAIN, &saved) }; + } + } + + /// Take the terminal back after handing it to a child. + /// + /// `restore` gives the saved settings back and forgets them, which is + /// right on the way out and wrong in a launcher: the menu has to + /// return to cbreak once the widget it started has finished with the + /// terminal. Anything typed while the child had the keyboard belongs + /// to the child, so what is left of it is dropped rather than + /// delivered to the menu a moment later. + pub fn reclaim(&mut self) { + if self.saved.is_some() || unsafe { libc::isatty(self.fd) } != 1 { + return; + } + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(self.fd, &mut saved) } != 0 { + return; + } + let mut raw = saved; + raw.c_lflag &= !(libc::ICANON | libc::ECHO); + raw.c_cc[libc::VMIN] = 0; + raw.c_cc[libc::VTIME] = 0; + unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &raw) }; + remember_termios(self.fd, saved); + self.saved = Some(saved); + self.buf.clear(); + } + + /// Every key waiting, decoded. Empty when nothing has been pressed. + pub fn poll(&mut self) -> Vec { + if self.saved.is_none() { + return Vec::new(); + } + let mut chunk = [0u8; 64]; + loop { + let flags = unsafe { libc::fcntl(self.fd, libc::F_GETFL) }; + unsafe { libc::fcntl(self.fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; + let n = std::io::stdin().read(&mut chunk); + unsafe { libc::fcntl(self.fd, libc::F_SETFL, flags) }; + match n { + Ok(0) | Err(_) => break, + Ok(n) => self.buf.extend_from_slice(&chunk[..n]), + } + } + self.pending + .push_str(&String::from_utf8_lossy(&self.buf).to_string()); + self.buf.clear(); + decode(&mut self.pending, &mut self.lone_esc) + } +} + +impl Drop for Keyboard { + fn drop(&mut self) { + self.restore(); + } +} + +/// Turn a run of input bytes into key names. +/// How long the unmapped escape sequence at the front of `s` is, in chars. +/// +/// The shapes a terminal actually sends: CSI is ESC [ then parameter +/// digits and semicolons then a final letter or tilde; SS3 is ESC O then +/// one letter. Anything matching is a key this program does not use - +/// Delete, a function key, a focus report, a bracketed-paste marker - and +/// must be swallowed whole. Emitting it a character at a time is how F9 +/// came to reset the pomodoro count, "0" being a real binding in clocks. +fn escape_len(s: &[char]) -> Option { + if s.first() != Some(&'\x1b') { + return None; + } + match s.get(1) { + Some('[') => { + let mut i = 2; + while matches!(s.get(i), Some(c) if c.is_ascii_digit() || *c == ';') { + i += 1; + } + match s.get(i) { + Some(c) if c.is_ascii_alphabetic() || *c == '~' => Some(i + 1), + _ => None, + } + } + Some('O') => match s.get(2) { + Some(c) if c.is_ascii_alphabetic() => Some(3), + _ => None, + }, + _ => None, + } +} + +/// Whether `s` is the start of a sequence that has not finished arriving. +/// +/// ESC, ESC O, and ESC [ with only parameter characters after it can all +/// still become a key. Holding them costs nothing - the next read either +/// completes the sequence or makes it a malformed escape - and it is the +/// difference between a torn arrow key being an arrow and it being three +/// characters that other keys are bound to. +fn still_arriving(s: &[char]) -> bool { + match s { + [] => false, + ['\x1b'] => true, + ['\x1b', 'O'] => true, + ['\x1b', '[', rest @ ..] => rest + .iter() + .all(|c| c.is_ascii_digit() || *c == ';'), + _ => false, + } +} + +/// Turn what the terminal sent into key names, leaving anything incomplete +/// in `buf` for the next poll. +/// +/// This mirrors common.py's Keyboard.poll rather than reinventing it: the +/// order of the checks is what decides whether an unknown key is silently +/// ignored or arrives as a handful of characters that other keys are bound +/// to. +fn decode(buf: &mut String, lone_esc: &mut bool) -> Vec { + const SEQUENCES: &[(&str, &str)] = &[ + ("\x1b[A", "up"), + ("\x1b[B", "down"), + ("\x1b[C", "right"), + ("\x1b[D", "left"), + ("\x1bOA", "up"), + ("\x1bOB", "down"), + ("\x1bOC", "right"), + ("\x1bOD", "left"), + ("\x1b[5~", "pgup"), + ("\x1b[6~", "pgdn"), + ("\x1b[H", "home"), + ("\x1b[F", "end"), + // The other encoding of the same two keys. Which one arrives + // depends on the terminal and on whether it is in application + // cursor mode, so both have to be understood or Home and End work + // on some clients and not others. + ("\x1b[1~", "home"), + ("\x1b[4~", "end"), + ]; + let mut keys = Vec::new(); + let mut chars: Vec = buf.chars().collect(); + let mut at = 0usize; + while at < chars.len() { + if chars[at] == '\x1b' { + let rest: String = chars[at..].iter().collect(); + // Longest wins: ESC [ 1 ~ is Home, not ESC [ 1 followed by ~. + let found = SEQUENCES + .iter() + .filter(|(seq, _)| rest.starts_with(seq)) + .max_by_key(|(seq, _)| seq.chars().count()); + if let Some((seq, name)) = found { + keys.push((*name).to_string()); + at += seq.chars().count(); + continue; + } + if let Some(len) = escape_len(&chars[at..]) { + at += len; // a sequence this program does not map; drop it + continue; + } + if still_arriving(&chars[at..]) { + // Either a bare ESC or a half-arrived sequence. A bare ESC + // only counts as Escape once a second poll has found + // nothing following it; a longer prefix is simply kept, + // since it cannot be Escape at all. + if chars.len() - at == 1 { + if *lone_esc { + at += 1; + *lone_esc = false; + keys.push("esc".to_string()); + } else { + *lone_esc = true; + } + } + break; + } + at += 1; // malformed; discard the ESC and carry on + continue; + } + let ch = chars[at]; + at += 1; + match ch { + '\r' | '\n' => keys.push("enter".to_string()), + '\t' => keys.push("tab".to_string()), + '\x7f' | '\x08' => keys.push("backspace".to_string()), + c => keys.push(c.to_string()), + } + } + chars.drain(..at); + *buf = chars.into_iter().collect(); + if buf != "\x1b" { + *lone_esc = false; + } + keys +} + +/// Run a command and give up on it after `seconds`. +/// +/// std's `.output()` waits forever, and every one of these commands talks +/// to something that can stop answering - a tailnet coordination server, +/// a Herdr socket, a wedged `ss`. The Pythons all pass a timeout; the port +/// dropped them, so a hung child froze the poll thread with no error and +/// the pane kept drawing its last frame as though it were current. +/// +/// Returns everything the child produced, so a caller that needs the exit +/// status or stderr - to say why a command refused - still has them. Both +/// pipes are captured for that reason: `run` below and `ports`'s `refusal` +/// each read `stderr` to name a refusal, and while it was sent to /dev/null +/// they had nothing to read, so "tailscale serve needs an operator" and +/// every other permission, login and bad-argument message arrived as an +/// exit status or as the word "refused". +pub fn run_full(args: &[&str], seconds: u64) -> Result { + use std::process::{Command, Stdio}; + use std::sync::mpsc; + let Some((program, rest)) = args.split_first() else { + return Err("no command given".into()); + }; + let child = Command::new(program) + .args(rest) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("{}: {}", program, e))?; + let pid = child.id() as i32; + let (tx, rx) = mpsc::channel(); + // wait_with_output drains both pipes while it waits; doing the wait on + // this side and the reads afterwards would deadlock on a child that + // fills its stdout or stderr buffer. + std::thread::spawn(move || { + let _ = tx.send(child.wait_with_output()); + }); + match rx.recv_timeout(std::time::Duration::from_secs(seconds)) { + Ok(Ok(out)) => Ok(out), + Ok(Err(e)) => Err(format!("{}: {}", program, e)), + Err(_) => { + // SIGKILL rather than SIGTERM: this one has already ignored the + // time it was given, and the reader thread ends when it dies. + unsafe { libc::kill(pid, libc::SIGKILL) }; + Err(format!("{} did not answer in {}s", program, seconds)) + } + } +} + +/// The same, reduced to the stdout of a command that succeeded. +/// +/// A command that ran and failed is an error here, not empty output: the +/// callers that want the difference use run_full and read the status. +pub fn run(args: &[&str], seconds: u64) -> Result { + let out = run_full(args, seconds)?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).to_string()) + } else { + let why = String::from_utf8_lossy(&out.stderr); + let why: String = why.split_whitespace().collect::>().join(" "); + Err(if why.is_empty() { + format!("{} exited {}", args[0], out.status) + } else { + why.chars().take(200).collect() + }) + } +} + +/// Print the doc comment and leave, when asked for help. +pub fn maybe_help(doc: &str) { + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "-h" || a == "--help") { + println!("{}", doc.trim()); + std::process::exit(0); + } + // Answered here rather than by each widget, for the same reason `--help` + // is: fourteen binaries that disagree about how to say their own version + // are fourteen answers to one question. netwatch used to answer this + // itself and said "netwatch 1.1" while the workspace was at 0.1.0 - a + // number nothing set, kept up to date by nobody. + if args.iter().any(|a| a == "-V" || a == "--version") { + println!("{}", version()); + std::process::exit(0); + } +} + +/// What this binary is, in one line. +/// +/// The version, the commit it was built from, and the date of that commit. +/// The version alone is not enough to identify a build: it changes only at a +/// release, and almost every binary anybody runs is somewhere between two. +/// +/// Any of the three may read `unknown` - built from a tarball with no `.git`, +/// say. That is the honest answer and it is not a build failure; a +/// `--version` that had to guess would be worse than one that admits it. +pub fn version() -> String { + format!( + "{} {} ({}, {})", + binary_name(), + env!("CARGO_PKG_VERSION"), + env!("TOYS_COMMIT"), + env!("TOYS_BUILD_DATE"), + ) +} + +/// The name this binary was invoked as, for the first word of `--version`. +fn binary_name() -> String { + std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) + .unwrap_or_else(|| "terminal-toys".into()) +} + +#[cfg(test)] +mod tests { + #[test] + fn the_window_chases_a_cursor_it_cannot_see() { + // Stated as what the reader sees rather than as the arithmetic: + // wherever the cursor is, the window has to contain it, and it has + // to move as little as it can to do that. + let holds = |at: usize, row: usize, room: usize| row >= at && row < at + room; + for room in [1usize, 3, 20] { + for start in [0usize, 5, 30] { + for row in [0usize, 4, 7, 12, 40] { + let moved = follow(start, row, room); + assert!(holds(moved, row, room), "row {} not in {}..+{}", row, moved, room); + // Not moved at all when it did not need to be. + if holds(start, row, room) { + assert_eq!(moved, start, "moved without needing to"); + } + } + } + } + // Reaching down puts the cursor on the last row, not past it. + assert_eq!(follow(0, 40, 20) + 20, 41); + // Reaching up puts it on the first. + assert_eq!(follow(30, 4, 20), 4); + } + + + // The rule every widget with focusable sections follows. It is tested + // here rather than in each widget because "the same rule everywhere" is + // a claim two hand-written copies cannot keep. + + #[test] + fn tab_walks_the_sections_and_then_off_the_end() { + let lens = [3usize, 2, 4]; + assert_eq!(next_section(None, &lens), Some(0), "opens into the first"); + assert_eq!(next_section(Some(0), &lens), Some(1)); + assert_eq!(next_section(Some(1), &lens), Some(2)); + assert_eq!( + next_section(Some(2), &lens), + None, + "the last section leads out, not back to the first" + ); + } + + #[test] + fn tab_steps_over_sections_with_nothing_in_them() { + // The middle one is empty: focusing it would offer "select" over + // nothing and the next arrow would silently leave again. + assert_eq!(next_section(Some(0), &[3, 0, 4]), Some(2)); + assert_eq!(next_section(None, &[0, 0, 4]), Some(2), "skips a run of them"); + assert_eq!(next_section(Some(0), &[3, 0, 0]), None, "nothing left to enter"); + assert_eq!(next_section(None, &[0, 0, 0]), None, "an empty screen has nowhere to go"); + } + + #[test] + fn walking_off_a_section_steps_into_the_next_one() { + let lens = [3usize, 2, 4]; + // down, out of the first section and into the top of the second + assert_eq!(step_across_sections(0, 1, &lens, true), Some((0, 2))); + assert_eq!(step_across_sections(0, 2, &lens, true), Some((1, 0))); + // up, out of the second and into the *bottom* of the first - the row + // you were about to reach if the two had been one list + assert_eq!(step_across_sections(1, 1, &lens, false), Some((1, 0))); + assert_eq!(step_across_sections(1, 0, &lens, false), Some((0, 2))); + } + + #[test] + fn only_the_two_far_ends_leave_the_sections() { + let lens = [3usize, 2, 4]; + assert_eq!( + step_across_sections(0, 0, &lens, false), + None, + "up from the first row of the first section" + ); + assert_eq!( + step_across_sections(2, 3, &lens, true), + None, + "down from the last row of the last section" + ); + // and nowhere in between + for (focus, at) in [(0usize, 2usize), (1, 0), (1, 1), (2, 0)] { + assert!( + step_across_sections(focus, at, &lens, true).is_some() + || step_across_sections(focus, at, &lens, false).is_some(), + "section {} row {} had nowhere to go", + focus, + at + ); + } + } + + #[test] + fn a_whole_section_can_be_crossed_in_either_direction() { + // Every row of every section, in order, walking down and back up. + let lens = [2usize, 1, 3]; + let mut seen = vec![(0usize, 0usize)]; + let (mut f, mut a) = (0usize, 0usize); + while let Some((nf, na)) = step_across_sections(f, a, &lens, true) { + seen.push((nf, na)); + f = nf; + a = na; + } + assert_eq!( + seen, + vec![(0, 0), (0, 1), (1, 0), (2, 0), (2, 1), (2, 2)], + "walking down did not visit every row once, in order" + ); + let mut back = vec![(f, a)]; + while let Some((nf, na)) = step_across_sections(f, a, &lens, false) { + back.push((nf, na)); + f = nf; + a = na; + } + back.reverse(); + assert_eq!(back, seen, "walking back up did not retrace the same path"); + } + + #[test] + fn empty_sections_are_stepped_over_in_both_directions() { + assert_eq!(step_across_sections(0, 2, &[3, 0, 4], true), Some((2, 0))); + assert_eq!(step_across_sections(2, 0, &[3, 0, 4], false), Some((0, 2))); + assert_eq!( + step_across_sections(0, 2, &[3, 0, 0], true), + None, + "nothing below but empties" + ); + } + + #[test] + fn a_section_that_empties_under_the_cursor_escapes() { + // Sections are rebuilt every frame and can shrink or vanish. + assert_eq!(step_across_sections(1, 0, &[3, 0, 4], true), Some((2, 0))); + assert_eq!(step_across_sections(1, 0, &[3, 0, 4], false), Some((0, 2))); + assert_eq!(step_across_sections(0, 7, &[3, 2, 0], false), None); + assert_eq!(step_across_sections(0, 7, &[3, 2, 0], true), Some((1, 0))); + } + + /// One more hint costs at most one more line. + /// + /// netwatch's detail screen leans on this: it sizes the body against the + /// footer, then adds the scroll position as a seventh hint and re-packs. + /// It reserves exactly one line for that. If a hint could ever push the + /// footer two lines further, the body would lose a row the position had + /// already counted and the label would name a row nobody can see. + /// + /// It holds because the packing is greedy and in order: whatever comes + /// before a hint packs the same way whether that hint follows or not, so + /// the new one either joins the last line or starts a single new one. + #[test] + fn appending_a_hint_adds_at_most_one_line() { + let hint = |t: &str| vec![("", t.to_string())]; + let base = vec![ + hint("↑↓ select"), + hint("tab next section"), + hint("[c]opy"), + hint("[r]ezero"), + hint("←/esc back"), + hint("[q]uit"), + ]; + for width in 8..=120usize { + let before = pack_hints(&base, width, " ").len(); + let mut after_hints = base.clone(); + after_hints.push(hint("rows 1- 43 of 45")); + let after = pack_hints(&after_hints, width, " ").len(); + assert!( + after == before || after == before + 1, + "width {}: {} lines became {}", + width, + before, + after + ); + // And the hints that were already there are untouched. + assert_eq!( + pack_hints(&base, width, " ")[..before.saturating_sub(1)], + pack_hints(&after_hints, width, " ")[..before.saturating_sub(1)], + "width {}: an earlier line was repacked", + width + ); + } + } + use super::*; + + #[test] + fn a_file_without_the_executable_bit_is_still_missing() { + // shutil.which, which the Python uses, requires the bit. Checking + // only is_file() would let a readable non-executable of the right + // name count as the tool, so the widget would start and then fail + // on every call rather than saying up front what it needs. + let dir = std::env::temp_dir().join(format!("toys-missing-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let tool = dir.join("definitely-not-a-real-tool"); + std::fs::write(&tool, "#!/bin/sh\n").unwrap(); + + // PATH is process-wide and cargo runs a binary's tests in parallel, + // so this is held for as long as the value is borrowed. `missing()` + // reads PATH, and a second test calling it mid-swap would see a + // directory holding one fake tool - failing for a reason that has + // nothing to do with what it was testing, and only sometimes. + static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let held = std::env::var("PATH").unwrap_or_default(); + std::env::set_var("PATH", dir.to_string_lossy().to_string()); + + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o644)).unwrap(); + let not_executable = missing(&["definitely-not-a-real-tool"]); + + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + let executable = missing(&["definitely-not-a-real-tool"]); + + std::env::set_var("PATH", held); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(not_executable, vec!["definitely-not-a-real-tool"], "0644 counted as present"); + assert!(executable.is_empty(), "0755 was not found: {:?}", executable); + } + + + #[test] + fn a_query_survives_its_own_newlines() { + // A GraphQL query is several lines. Unescaped, curl's config parser + // would read the second one as another option. + assert_eq!(quoted("query {\n issues\n}"), "\"query {\\n issues\\n}\""); + assert_eq!(quoted("a\tb"), "\"a\\tb\""); + } + + #[test] + fn the_headers_that_count_are_the_ones_next_to_the_body() { + // A redirect leaves its own block in front. The response is the one + // nearest the body, and everything before it is history. + let text = "HTTP/2 301\r\nlocation: /x\r\n\r\n\ + HTTP/2 200\r\nX-RateLimit-Requests-Remaining: 2491\r\n\r\n\ + {\"data\": 1}"; + let (head, body) = split_response(text); + assert_eq!(head[0], "HTTP/2 200"); + assert_eq!(body, "{\"data\": 1}"); + // A body containing a blank line of its own is not mistaken for a + // header block, because a block has to open with HTTP/. + let plain = "HTTP/2 200\r\n\r\nline\n\nline"; + let (head, body) = split_response(plain); + assert_eq!(head[0], "HTTP/2 200"); + assert_eq!(body, "line\n\nline"); + } + + #[test] + fn a_curl_config_value_survives_spaces_and_quotes() { + assert_eq!(quoted("simple"), "\"simple\""); + // A header is "Name: value" and the space is the whole reason this + // exists - unquoted, curl would read the rest as another option. + assert_eq!( + quoted("Authorization: Bearer abc"), + "\"Authorization: Bearer abc\"" + ); + assert_eq!(quoted("a\"b"), "\"a\\\"b\""); + assert_eq!(quoted("a\\b"), "\"a\\\\b\""); + } + + #[test] + fn a_stacked_bar_is_exactly_its_width() { + let hue = |n: u8| rgb(n, n, n); + // Thirds do not divide ten, and the last segment absorbs the + // rounding rather than leaving a gap at the end. + let parts = vec![ + (1.0 / 3.0, hue(1)), + (1.0 / 3.0, hue(2)), + (1.0 / 3.0, hue(3)), + ]; + let drawn: usize = stacked_bar(&parts, 10) + .iter() + .map(|(_, t)| t.chars().count()) + .sum(); + assert_eq!(drawn, 10); + // A part that rounds to nothing takes no segment at all, rather + // than an empty one. + let tiny = vec![(0.001, hue(1)), (0.999, hue(2))]; + assert_eq!(stacked_bar(&tiny, 10).len(), 1); + } + + #[test] + fn a_meter_fills_and_clamps() { + assert_eq!(meter(0.0, 4), "░░░░"); + assert_eq!(meter(0.5, 4), "██░░"); + assert_eq!(meter(1.0, 4), "████"); + // Over and under are clamped: a percentage above 100 must not + // draw a bar wider than its track. + assert_eq!(meter(2.0, 4), "████"); + assert_eq!(meter(-1.0, 4), "░░░░"); + } + + #[test] + fn bars_resolve_eighths_of_a_row() { + let hue = rgb(0, 0, 0); + let columns = vec![(1.0, hue.clone()), (0.5, hue.clone()), (0.0, hue.clone())]; + let rows = vbars(&columns, 1, 1.0); + assert_eq!(rows[0][0].1, "█"); + assert_eq!(rows[0][1].1, "▄"); + assert_eq!(rows[0][2].1, " "); + // Hanging downward there is only one partial glyph, so half a cell + // rounds to it and less than half to nothing. + let down = vbars_down(&columns, 1, 1.0); + assert_eq!(down[0][0].1, "█"); + assert_eq!(down[0][1].1, "▀"); + assert_eq!(down[0][2].1, " "); + } + + #[test] + fn the_placeholder_moves_but_never_leaves_the_track() { + // Every column stays inside the range a bar chart can draw, at + // every tick - a value outside it would render as an empty cell + // and read as data rather than as waiting. + for tick in 0..40 { + for value in dance(12, tick, 0.0) { + assert!((0.08..=1.0).contains(&value), "{} at tick {}", value, tick); + } + } + // Deterministic, so a frame can be reproduced. + assert_eq!(dance(4, 7, 0.0), dance(4, 7, 0.0)); + assert_ne!(dance(4, 7, 0.0), dance(4, 8, 0.0)); + } + + #[test] + fn the_shimmer_covers_its_width_and_moves() { + let drawn = |tick: usize| -> String { + skeleton(20, tick, 7).iter().map(|(_, t)| t.clone()).collect() + }; + // Always exactly its width, whatever the phase: the row it stands + // in for has a fixed size. + for tick in 0..40 { + assert_eq!(drawn(tick).chars().count(), 20, "at tick {}", tick); + } + // And the highlight actually travels, or it is just a grey bar. + let runs = |tick: usize| skeleton(20, tick, 7).len(); + assert!((0..40).map(runs).collect::>().len() > 1); + } + + #[test] + fn spread_fills_its_room_exactly() { + // Twenty-eight days across fifty-nine columns: the three left over + // go to the leftmost bars rather than leaving the chart short. + let widths = spread(28, 59); + assert_eq!(widths.len(), 28); + assert_eq!(widths.iter().sum::(), 59); + assert_eq!(widths[0], 3); + assert_eq!(widths[27], 2); + // More bars than columns is one cell each, and the caller decides + // what to drop - returning fewer widths would lose data silently. + assert_eq!(spread(10, 4), vec![1; 10]); + assert!(spread(0, 10).is_empty()); + } + + #[test] + fn a_blend_reaches_both_ends() { + assert_eq!(mix((0, 0, 0), (10, 20, 30), 0.0), rgb(0, 0, 0)); + assert_eq!(mix((0, 0, 0), (10, 20, 30), 1.0), rgb(10, 20, 30)); + assert_eq!(mix((0, 0, 0), (10, 20, 30), 0.5), rgb(5, 10, 15)); + } + + #[test] + fn heat_runs_green_to_red_through_amber() { + // The ends and the middle, since every widget reads the same scale + // and a drift in it would make one pane disagree with the next. + assert_eq!(heat(0.0), rgb(40, 255, 120)); + assert_eq!(heat(0.5), rgb(255, 240, 20)); + assert_eq!(heat(1.0), rgb(255, 40, 30)); + // Out of range is clamped rather than wrapped: a CPU reading over + // 100% on a multicore box must not come back green. + assert_eq!(heat(2.0), heat(1.0)); + assert_eq!(heat(-1.0), heat(0.0)); + } + + #[test] + fn base64_matches_the_rfc_vectors() { + // Hand-rolled, and its output is invisible: the copy notice shows + // the URL whatever actually landed on the clipboard, so a wrong + // encoder would look like it worked. RFC 4648 section 10. + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!(base64(b"foob"), "Zm9vYg=="); + assert_eq!(base64(b"fooba"), "Zm9vYmE="); + assert_eq!(base64(b"foobar"), "Zm9vYmFy"); + // The two characters that separate base64 from base64url, and a + // byte above 127, since a URL may carry either. + assert_eq!(base64(&[0xfb, 0xff]), "+/8="); + assert_eq!(base64("é".as_bytes()), "w6k="); + } + + + #[test] + fn the_config_search_includes_the_working_directory() { + // The bug this exists for: a compiled binary looked only beside + // itself, which is target/release, and silently used defaults + // while a real config sat in the project directory. + let paths = config_paths(); + let cwd = std::env::current_dir().unwrap().join("config.json"); + assert!(paths.contains(&cwd), "cwd missing from {:?}", paths); + assert!( + paths.iter().any(|p| p.to_string_lossy().contains(".config/terminal-toys")), + "the xdg location must stay, for an installed binary" + ); + } + + #[test] + fn seg_counts_only_the_text() { + let red = rgb(255, 0, 0); + // Twelve printable cells asked for, twelve delivered, however many + // escape bytes rode along with them. + let line = seg(&[(red.as_str(), "hello world!!!".into())], 12); + let visible: String = line.replace(&red, ""); + assert_eq!(visible.chars().count(), 12); + } + + #[test] + fn pad_is_exact_in_both_directions() { + assert_eq!(pad("ab", 5).chars().count(), 5); + assert_eq!(pad("abcdefgh", 3), "abc"); + } + + #[test] + fn title_fills_the_width() { + let plain = strip(&title("clocks", 40, &rgb(0, 255, 170))); + assert_eq!(plain.chars().count(), 40); + assert!(plain.contains(" CLOCKS ")); + } + + /// One poll's worth of input, decoded from a fresh keyboard. + fn keys(text: &str) -> Vec { + let mut buf = text.to_string(); + let mut held = false; + decode(&mut buf, &mut held) + } + + #[test] + fn a_setting_that_is_not_a_choice_starts_from_the_first() { + let choices = [0.2f64, 0.5, 1.0, 2.0]; + assert_eq!(cycle(&choices, 0.2), 0.5); + assert_eq!(cycle(&choices, 2.0), 0.2, "the last wraps to the first"); + // A value from config or an argument that is not on the list. This + // returned 0.5 - the second - so the first choice could never be + // reached by pressing the key. + assert_eq!(cycle(&choices, 3.3), 0.2); + } + + #[test] + fn arrows_decode_to_names() { + assert_eq!(keys("\x1b[A"), vec!["up"]); + assert_eq!(keys("\x1b[B\x1b[B"), vec!["down", "down"]); + assert_eq!(keys("q"), vec!["q"]); + // Both encodings of Home and End, since terminals disagree. + assert_eq!(keys("\x1b[H"), vec!["home"]); + assert_eq!(keys("\x1b[1~"), vec!["home"]); + assert_eq!(keys("\x1b[F"), vec!["end"]); + assert_eq!(keys("\x1b[4~"), vec!["end"]); + assert_eq!(keys("\r"), vec!["enter"]); + } + + #[test] + fn a_key_this_program_does_not_use_is_dropped_whole() { + // Each of these used to arrive as "esc" plus its own bytes as + // separate keys, and those bytes are live bindings elsewhere: "0" + // resets the pomodoro count in clocks, "1" and "2" reorder + // netwatch. Pressing F9 must do nothing, not reset a counter. + for seq in [ + "\x1b[3~", // Delete + "\x1b[20~", // F9 + "\x1b[15~", // F5 + "\x1bOP", // F1 + "\x1b[I", // focus in + "\x1b[O", // focus out + "\x1b[200~", // bracketed paste begins + "\x1b[Z", // shift-tab + "\x1b[1;5C", // ctrl-right + ] { + assert_eq!(keys(seq), Vec::::new(), "{:?} leaked a key", seq); + } + // And it does not swallow what follows it. + assert_eq!(keys("\x1b[3~q"), vec!["q"]); + } + + #[test] + fn a_bare_escape_waits_one_poll_before_it_counts() { + // ESC alone is indistinguishable from the first byte of a sequence + // still arriving, so it is held. Two polls with nothing following + // make it Escape; a poll that completes an arrow makes it an arrow. + let mut buf = "\x1b".to_string(); + let mut held = false; + assert_eq!(decode(&mut buf, &mut held), Vec::::new()); + assert!(held); + assert_eq!(decode(&mut buf, &mut held), vec!["esc"]); + assert!(buf.is_empty()); + + // The torn arrow: ESC in one read, "[A" in the next. + let mut buf = "\x1b".to_string(); + let mut held = false; + assert_eq!(decode(&mut buf, &mut held), Vec::::new()); + buf.push_str("[A"); + assert_eq!(decode(&mut buf, &mut held), vec!["up"]); + assert!(buf.is_empty()); + } + + #[test] + fn an_incomplete_sequence_stays_in_the_buffer() { + // Half of Home arrives; nothing is emitted and the half is kept, + // rather than being spent as "esc" and a bracket. + let mut buf = "\x1b[1".to_string(); + let mut held = false; + assert_eq!(decode(&mut buf, &mut held), Vec::::new()); + assert_eq!(buf, "\x1b[1"); + buf.push('~'); + assert_eq!(decode(&mut buf, &mut held), vec!["home"]); + } + + #[test] + fn hints_wrap_rather_than_split() { + let dim = rgb(1, 1, 1); + let hints: Vec> = vec![ + vec![(dim.as_str(), "[a]lpha".into())], + vec![(dim.as_str(), "[b]ravo".into())], + vec![(dim.as_str(), "[c]harlie".into())], + ]; + let lines = pack_hints(&hints, 20, " "); + assert!(lines.len() > 1); + for line in &lines { + let plain = strip(line); + assert!(plain.chars().count() <= 20, "{:?} is too wide", plain); + // A hint is never cut in half. + assert!(!plain.ends_with("[c]har")); + } + } + + fn strip(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' { + while let Some(n) = chars.next() { + if n.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out + } + + /// A list that is there and empty said something; a list that is not + /// there did not. The first version read both as "unset", so + /// `"hosts": []` had latency pinging the two resolvers its config had + /// just declined. + #[test] + fn an_empty_list_is_an_answer_and_a_missing_one_is_not() { + let fallback = ["1.1.1.1", "8.8.8.8"]; + let said_none = serde_json::json!({ "hosts": [] }); + assert!( + cfg_strings(&said_none, "hosts", &fallback).is_empty(), + "an empty list is the answer, not the absence of one" + ); + let silent = serde_json::json!({ "window": 600 }); + assert_eq!( + cfg_strings(&silent, "hosts", &fallback), + vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()], + "nobody said, so the default is a kindness" + ); + // Not a list at all is not an answer either. + let wrong = serde_json::json!({ "hosts": "1.1.1.1" }); + assert_eq!(cfg_strings(&wrong, "hosts", &fallback).len(), 2); + let given = serde_json::json!({ "hosts": ["a.example", "b.example"] }); + assert_eq!( + cfg_strings(&given, "hosts", &fallback), + vec!["a.example".to_string(), "b.example".to_string()] + ); + } + + #[test] + fn a_broken_refresh_does_not_reach_from_secs_f64() { + // Duration::from_secs_f64 panics on anything that is not finite + // and positive. A config of -1, or `nan` from a mistyped `-n`, + // used to take the poller down after the first read. + assert_eq!(poll_secs(-1.0, 120.0), 120.0); + assert_eq!(poll_secs(f64::NAN, 30.0), 30.0); + assert_eq!(poll_secs(f64::INFINITY, 4.0), 4.0); + assert_eq!(poll_secs(0.0, 2.0), 2.0); + assert_eq!(poll_secs(15.0, 120.0), 15.0); + // A broken fallback still has to be a duration. + assert_eq!(poll_secs(-1.0, f64::NAN), 1.0); + // And the bytes the handler writes are the same four sequences + // restore_screen formats - so a drift here is a drift on Ctrl-C. + assert_eq!( + format!("{}{}{}{}", SHOW, RST, CLEAR, HOME), + SCREEN_RESTORE + ); + } + + /// The status names that a request was refused; only the body names + /// what to do about it. + #[test] + fn a_refused_post_carries_what_the_api_said() { + let said = refused(401, "{\"message\":\"Bad credentials\"}"); + assert!(said.contains("401"), "{:?}", said); + assert!(said.contains("Bad credentials"), "{:?}", said); + // A body over several lines still arrives as one row. + let wrapped = refused(403, "{\n \"message\": \"Resource not accessible\"\n}"); + assert!(!wrapped.contains('\n'), "{:?}", wrapped); + assert!(wrapped.contains("Resource not accessible"), "{:?}", wrapped); + // An error page cannot spend the whole pane. + let long = refused(502, &"x".repeat(4000)); + assert!(long.chars().count() < 240, "{} characters", long.chars().count()); + // And a server that says nothing still says which refusal it was. + assert_eq!(refused(500, " \n"), "HTTP 500"); + } + + /// stderr is where a command says why it refused. It was sent to + /// /dev/null, so `run` and `ports`'s `refusal` - both of which read it - + /// had nothing to read, and a permission or login failure arrived as an + /// exit status. + #[test] + fn a_command_that_failed_hands_back_what_it_complained() { + let out = run_full(&["sh", "-c", "echo 'needs an operator' >&2; exit 3"], 5) + .expect("sh ran"); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("needs an operator"), + "stderr was {:?}", + String::from_utf8_lossy(&out.stderr) + ); + let why = run(&["sh", "-c", "echo 'needs an operator' >&2; exit 3"], 5) + .expect_err("exit 3 is an error"); + assert!(why.contains("needs an operator"), "{:?}", why); + // Capturing stderr must not cost stdout on the way past. + assert_eq!(run(&["sh", "-c", "echo fine"], 5).unwrap().trim(), "fine"); + } +} diff --git a/core/tests/bounded.rs b/core/tests/bounded.rs new file mode 100644 index 0000000..ad1e490 --- /dev/null +++ b/core/tests/bounded.rs @@ -0,0 +1,33 @@ + +// A command that never answers must be given up on, and killed. +#[test] +fn a_command_that_never_answers_is_given_up_on() { + let began = std::time::Instant::now(); + let got = toys_core::run(&["sleep", "30"], 2); + let took = began.elapsed().as_secs_f64(); + assert!(got.is_err(), "sleep 30 returned {:?}", got); + assert!(took < 5.0, "waited {:.1}s for a 2s limit", took); + assert!( + got.unwrap_err().contains("did not answer"), + "the reason should say what happened" + ); +} + +#[test] +fn a_command_that_answers_comes_back_with_its_output() { + let got = toys_core::run(&["echo", "hello"], 5).expect("echo"); + assert_eq!(got.trim(), "hello"); +} + +#[test] +fn a_command_that_fails_is_an_error_not_empty_output() { + // `false` prints nothing and exits 1. The old run() turned that into + // an empty string, which reads on screen as a source with no data. + assert!(toys_core::run(&["false"], 5).is_err()); +} + +#[test] +fn a_command_that_is_not_installed_says_so() { + let got = toys_core::run(&["definitely-not-a-real-binary-xyz"], 5); + assert!(got.is_err()); +} diff --git a/deployments.py b/deployments.py deleted file mode 100755 index d737b15..0000000 --- a/deployments.py +++ /dev/null @@ -1,627 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Vercel deployments, live. - -Shows deployment activity over time, build-duration trend, and the most recent -deployments with their state, project, branch, commit and build time. - - python3 deployments.py [-n SECONDS] [-t TEAM_ID] [project ...] - -Polls every 15s by default (-n changes it, minimum 5s). One request per team -per poll, so the default is 4 polls/min — modest against the API's limits. - -Keys while running: up/down (also PgUp/PgDn, Home/End) move the selection, -Enter, i or c opens a full detail view for the selected deployment - state and -failure reason, timings, regions, commit, and everything worth copying on -number keys - r refreshes -now, f cycles the filter (all / failed / production), p cycles which project -is shown, q quits. - -Copying uses OSC 52, so the terminal you are sitting at performs it and the -text reaches your local clipboard even over SSH. If your terminal or -multiplexer blocks OSC 52, the sheet still shows each URL in full for mouse -selection. - -Credentials: `deployments.token` in config.json, or $VERCEL_TOKEN. Create one -at Account Settings -> Tokens. The Vercel CLI's own session is deliberately not -used - it expires within hours and only the CLI can refresh it, so anything -reading it goes dark overnight. The token is read locally and never printed. `vercel ls --all --format json` is an equivalent data source -but spawns a Node process per refresh, so this queries the REST API directly. -""" -import collections -import json -import os -import ssl -import sys -import threading -import time -import urllib.error -import urllib.request - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (RST, Keyboard, bar, bg, clipboard, config_paths, - config_token_warning, cycle, draw, load_config, maybe_help, - pack_hints, pad, rgb, seg, setup, size, title) - -_CFG = load_config("deployments", { - "token": "", # a Vercel token; keep it in config.json, not here - "token_env": "VERCEL_TOKEN", - "refresh": 15, # seconds between API polls (-n) - "limit": 100, # deployments per request (API maximum) - "teams": [], # empty = discover every team you can see - "projects": [], # empty = all projects -}) - -REFRESH = float(_CFG["refresh"]) -LIMIT = int(_CFG["limit"]) -API = "https://api.vercel.com" - -FILTERS = ("all", "failed", "production") - -READY = rgb(80, 235, 150) -BUILD = rgb(255, 200, 90) -ERROR = rgb(255, 95, 105) -QUEUE = rgb(120, 160, 220) -CANCEL = rgb(140, 145, 160) -# Contrast is measured against both the terminal background and the selected -# row's tint, which is the harder case. Body text clears WCAG AA (4.5:1) on -# both; GRID is decorative gridline dots only and is never used for text. -DIM = rgb(127, 147, 172) # secondary text: ages, labels (6.7:1 / 4.5:1) -GRID = rgb(71, 91, 116) # chart gridlines only, never text (3.0:1) -MSG = rgb(158, 174, 196) # commit subjects (9.3:1 / 6.3:1) -URL = rgb(130, 200, 255) # links (11.7:1 / 7.9:1) -HINT = rgb(126, 148, 173) # key hints (6.7:1 / 4.5:1) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -PROD = rgb(120, 180, 255) -SHA = rgb(190, 170, 255) -BRANCH = rgb(150, 210, 255) -SPARK = "▁▂▃▄▅▆▇█" -ACCENT = rgb(150, 210, 255) - -STATE_COLOR = {"READY": READY, "BUILDING": BUILD, "ERROR": ERROR, - "QUEUED": QUEUE, "INITIALIZING": QUEUE, "CANCELED": CANCEL} -SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" - - -def token(): - """A Vercel token, from config.json or the environment. - - Deliberately not the Vercel CLI's session: that expires within hours and - only the CLI can refresh it, so a panel reading it goes dark overnight. - Create one at Account Settings -> Tokens instead. Returns (token, source). - """ - if _CFG["token"]: - return _CFG["token"], "config" - tok = os.environ.get(_CFG["token_env"] or "VERCEL_TOKEN") - if tok: - return tok, "env" - return None, "missing" - - -def api(path, tok): - req = urllib.request.Request(API + path, - headers={"Authorization": "Bearer " + tok}) - ctx = ssl.create_default_context() - with urllib.request.urlopen(req, timeout=25, context=ctx) as r: - return json.load(r) - - -def discover_teams(tok): - """Every team the token can see, so deployments are not just personal. - - Returning [] on failure is deliberate - the personal scope still works - - but it also hid a caller passing token()'s whole tuple instead of its - first element, so the bare except now names what went wrong. - """ - try: - return [t["id"] for t in api("/v2/teams", tok).get("teams", [])] - except TypeError: - raise # a programming error, not a network one - except Exception: - return [] - - -def fetch_detail(dep, tok): - """Per-deployment detail: why it failed, timings, regions, aliases. - - The list endpoint carries none of this, so it is fetched on demand when the - info view opens rather than for 200 deployments nobody has asked about. - """ - q = "/v13/deployments/%s" % dep.get("uid") - if dep.get("_team"): - q += "?teamId=" + dep["_team"] - try: - return api(q, tok) - except Exception as e: - return {"_error": "%s: %s" % (type(e).__name__, e)} - - -class Store(object): - """Deployments fetched in the background, so the UI never blocks on HTTP.""" - - def __init__(self, teams, projects): - self.teams = teams - self.projects = projects - self.lock = threading.Lock() - self.deployments = [] - self.error = None - self.fetched_at = 0 - self.wake = threading.Event() - - def snapshot(self): - with self.lock: - return list(self.deployments), self.error, self.fetched_at - - def run(self): - try: - self.poll() - except Exception as e: # a daemon thread that dies - with self.lock: # silently looks like "no data" - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:60]) - - def poll(self): - while True: - tok, source = token() - if not tok: - with self.lock: - self.error = ("no token: set deployments.token in " - "config.json, or $%s" - % (_CFG["token_env"] or "VERCEL_TOKEN")) - else: - out, err = [], None - if source == "config": - err = config_token_warning() - scopes = self.teams or [None] - for team in scopes: - q = "/v6/deployments?limit=%d" % LIMIT - if team: - q += "&teamId=" + team - try: - got = api(q, tok).get("deployments", []) - for d in got: - d["_team"] = team # needed to fetch detail - out.extend(got) - except urllib.error.HTTPError as e: - err = "HTTP %s from Vercel%s" % ( - e.code, " (token expired? run `vercel login`)" - if e.code in (401, 403) else "") - except Exception as e: - err = "%s: %s" % (type(e).__name__, e) - if self.projects: - out = [d for d in out if d.get("name") in self.projects] - out.sort(key=lambda d: d.get("created", 0), reverse=True) - with self.lock: - if out or not err: - self.deployments = out - self.fetched_at = time.time() - self.error = err - self.wake.wait(REFRESH) - self.wake.clear() - - -def age(ms): - s = max(0, time.time() - ms / 1000.0) - if s < 90: - return "%ds" % s - if s < 5400: - return "%dm" % (s / 60) - if s < 172800: - return "%dh" % (s / 3600) - return "%dd" % (s / 86400) - - -def dur(seconds): - if seconds is None: - return " -- " - if seconds < 60: - return "%5.0fs" % seconds - return "%dm%02ds" % (seconds // 60, seconds % 60) - - -def build_seconds(d): - if d.get("ready") and d.get("buildingAt"): - return (d["ready"] - d["buildingAt"]) / 1000.0 - if d.get("state") in ("BUILDING", "QUEUED", "INITIALIZING") and d.get("buildingAt"): - return time.time() - d["buildingAt"] / 1000.0 - return None - - - -def wrap(text, width): - return [text[i:i + width] for i in range(0, len(text), width)] or [""] - - -def when(ms): - return time.strftime("%Y-%m-%d %H:%M", time.localtime(ms / 1000.0)) if ms else None - - -def copy_items(dep, detail): - """Everything worth copying out of a deployment.""" - meta = dep.get("meta") or {} - items = [] - if dep.get("inspectorUrl"): - items.append(("Deployment dashboard", dep["inspectorUrl"])) - if meta.get("branchAlias"): - items.append(("Branch preview", "https://" + meta["branchAlias"])) - if dep.get("url"): - items.append(("Commit preview", "https://" + dep["url"])) - if meta.get("githubPrId") and meta.get("githubOrg") and meta.get("githubRepo"): - items.append(("Pull request", "https://github.com/%s/%s/pull/%s" % ( - meta["githubOrg"], meta["githubRepo"], meta["githubPrId"]))) - if meta.get("githubCommitSha"): - items.append(("Commit SHA", meta["githubCommitSha"])) - if meta.get("githubCommitRef"): - items.append(("Branch name", meta["githubCommitRef"])) - err = (detail or {}).get("errorMessage") - if err: - items.append(("Error message", err)) - return items - - -def info_overlay(dep, detail, w, h, note): - """One deployment in full: state, timings, why it failed, and what to copy.""" - meta = dep.get("meta") or {} - d = detail or {} - rows = [title("deployment", w, PROD)] - - def field(label, value, color=TXT): - if value in (None, "", []): - return - rows.append(seg([(DIM, " %-11s" % label), (color, str(value))], w - 1)) - - state = dep.get("state", "?") - scol = STATE_COLOR.get(state, DIM) - field("project", dep.get("name"), ACCENT) - field("state", state.title() + (" (%s)" % d.get("errorStep") - if d.get("errorStep") else ""), scol) - field("target", "production" if dep.get("target") == "production" else "preview", - PROD if dep.get("target") == "production" else DIM) - field("created", when(dep.get("created") or dep.get("createdAt"))) - secs = build_seconds(dep) - if secs: - queued = ((dep.get("buildingAt", 0) - (dep.get("createdAt") or 0)) / 1000.0 - if dep.get("createdAt") and dep.get("buildingAt") else None) - field("build", dur(secs).strip() + (" queued %.0fs" % queued - if queued and queued > 0.5 else "")) - if d.get("regions"): - field("regions", ", ".join(d["regions"]) + - (" plan %s" % d["plan"] if d.get("plan") else "")) - aliases = d.get("alias") or [] - if aliases: - field("aliases", "%d assigned" % len(aliases), DIM) - - if d.get("errorMessage") or d.get("errorCode"): - rows.append("") - rows.append(LBL + " ── WHY IT FAILED ──") - if d.get("errorCode"): - rows.append(seg([(ERROR, " " + d["errorCode"])], w - 1)) - for line in wrap(d.get("errorMessage") or "", max(10, w - 4)): - rows.append(seg([(MSG, " " + line)], w - 1)) - if d.get("errorLink"): - rows.append(seg([(URL, " " + d["errorLink"])], w - 1)) - elif d.get("_error"): - rows.append("") - rows.append(seg([(ERROR, " detail unavailable: " + d["_error"])], w - 1)) - elif not d: - rows.append("") - rows.append(seg([(DIM, " loading detail…")], w - 1)) - - if meta.get("githubCommitSha"): - rows.append("") - rows.append(LBL + " ── COMMIT ──") - rows.append(seg([(SHA, " " + meta["githubCommitSha"][:7]), - (BRANCH, " " + (meta.get("githubCommitRef") or ""))], w - 1)) - for line in wrap((meta.get("githubCommitMessage") or "").split("\n")[0], - max(10, w - 4)): - rows.append(seg([(MSG, " " + line)], w - 1)) - who = meta.get("githubCommitAuthorName") or meta.get("githubCommitAuthorLogin") - if who: - rows.append(seg([(DIM, " by " + who)], w - 1)) - - pairs = copy_items(dep, detail) - if pairs: - rows.append("") - rows.append(LBL + " ── COPY ──") - for i, (label, value) in enumerate(pairs, 1): - short = value if len(value) <= w - 28 else value[:w - 31] + "…" - rows.append(seg([(READY, " [%d] " % i), (TXT, "%-21s " % label), - (URL, short)], w - 1)) - - while len(rows) < h - 2: - rows.append("") - rows.append(seg([(HINT, " press 1-%d to copy · esc or i to close" % len(pairs))], - w - 1)) - rows.append(seg([(READY, " " + note) if note else (DIM, "")], w - 1)) - return rows - - -def columns(w): - """Progressive disclosure: spend extra width on more content, not padding. - - Under 66 columns only the essentials fit. Above that the commit SHA and - branch appear. From 110 the metadata and commit subject share one line, so - twice as many deployments are visible in the same height. - """ - return { - "detail": w >= 66, - "single": w >= 110, - "project": 12 if w < 80 else (16 if w < 110 else 20), - "branch": max(12, min(34, w // 5)), - } - - -def activity(deps, w, hours=48): - """Deployments per time bucket, coloured by the worst outcome in it.""" - cols = max(10, w - 2) - now = time.time() * 1000 - span = hours * 3600000.0 - buckets = [[] for _ in range(cols)] - for d in deps: - off = now - d.get("created", now) - if 0 <= off < span: - buckets[cols - 1 - int(off / span * cols)].append(d) - peak = max((len(b) for b in buckets), default=0) - if not peak: - return [DIM + " no deployments in the last %dh" % hours], 0 - out, last = [" "], None - for b in buckets: - if not b: - if last != GRID: - out.append(GRID) - last = GRID - out.append("·") - continue - states = {x.get("state") for x in b} - col = (ERROR if "ERROR" in states else - BUILD if states & {"BUILDING", "QUEUED", "INITIALIZING"} else READY) - if col != last: - out.append(col) - last = col - out.append(SPARK[min(7, int((len(b) / float(peak)) * 7.99))]) - return ["".join(out)], peak - - -def main(): - maybe_help(__doc__) - global REFRESH - args = sys.argv[1:] - teams = list(_CFG["teams"]) - while args and args[0] in ("-n", "--refresh", "-t", "--team"): - if args[0] in ("-n", "--refresh"): - REFRESH = max(5.0, float(args[1])) - else: - teams.append(args[1]) - args = args[2:] - projects = set(args) or set(_CFG["projects"]) - - setup() - keyboard = Keyboard() - tok, _source = token() # token() returns (value, where it came from) - if tok and not teams: - teams = discover_teams(tok) - store = Store(teams, projects) - th = threading.Thread(target=store.run) - th.daemon = True - th.start() - - flt = "all" - details = {} # uid -> detail dict, fetched on demand - fetching = set() - only = None # project cycled with `p` - tick = 0 - selected = 0 # index into the filtered list - scroll = 0 # first visible row - overlay = False # copy sheet open - note = "" # transient confirmation - note_until = 0 - visible = 1 - shown = [] - while True: - tick += 1 - for key in keyboard.poll(): - if overlay: - if key in ("esc", "c", "i", "q", "Q", "enter"): - overlay = False - elif key.isdigit() and shown: - chosen = shown[min(selected, len(shown) - 1)] - pairs = copy_items(chosen, details.get(chosen.get("uid"))) - idx = int(key) - 1 - if 0 <= idx < len(pairs): - label, url = pairs[idx] - note = ("✓ copied %s" % label.lower()) if clipboard(url) \ - else "! no clipboard; select the text with the mouse" - note_until = time.time() + 3 - continue - if key in ("q", "Q"): - keyboard.restore() - raise SystemExit(0) - if key == "r": - store.wake.set() - elif key == "f": - flt = cycle(FILTERS, flt) - selected = 0 - elif key == "up": - selected = max(0, selected - 1) - elif key == "down": - selected += 1 - elif key == "pgup": - selected = max(0, selected - visible) - elif key == "pgdn": - selected += visible - elif key == "home": - selected = 0 - elif key == "end": - selected = max(0, len(shown) - 1) - elif key in ("c", "i", "enter"): - if shown: - overlay = True - note = "" - elif key == "p": - deps, _, _ = store.snapshot() - names = sorted({d.get("name") for d in deps if d.get("name")}) - options = [None] + names - only = options[(options.index(only) + 1) % len(options)] \ - if only in options else (names[0] if names else None) - selected = 0 - - w, h = size() - deps, err, fetched = store.snapshot() - if note and time.time() > note_until: - note = "" - shown = deps - if only: - shown = [d for d in shown if d.get("name") == only] - if flt == "failed": - shown = [d for d in shown if d.get("state") in ("ERROR", "CANCELED")] - elif flt == "production": - shown = [d for d in shown if d.get("target") == "production"] - - selected = max(0, min(selected, len(shown) - 1)) if shown else 0 - if overlay and shown: - chosen = shown[selected] - uid = chosen.get("uid") - if uid and uid not in details and uid not in fetching: - fetching.add(uid) - - def grab(dep=chosen, key=uid): - tok, _ = token() - details[key] = fetch_detail(dep, tok) if tok else {} - fetching.discard(key) - - t = threading.Thread(target=grab) - t.daemon = True - t.start() - draw(info_overlay(chosen, details.get(uid), w, h, note), w, h) - time.sleep(0.1) - continue - - states = collections.Counter(d.get("state") for d in deps) - projects_seen = len({d.get("name") for d in deps}) - rows = [title("vercel deployments", w, PROD)] - - live = sum(states[s] for s in ("BUILDING", "QUEUED", "INITIALIZING")) - head = [(DIM, " %d deploys" % len(deps)), - (DIM, " · %d proj" % projects_seen), - (READY, " %d ready" % states.get("READY", 0))] - if states.get("ERROR"): - head.append((ERROR, " %d error" % states["ERROR"])) - if live: - head.append((BUILD, " %s %d building" % (SPINNER[tick % len(SPINNER)], live))) - head.append((DIM, " %s ago" % (age(fetched * 1000) if fetched else "--"))) - rows.append(seg(head, w - 1)) - if err: - rows.append(seg([(ERROR, " ! " + err)], w - 1)) - filt_bits = [] - if flt != "all": - filt_bits.append(flt) - if only: - filt_bits.append(only) - if filt_bits: - rows.append(seg([(BUILD, " filter: " + " + ".join(filt_bits))], w - 1)) - rows.append("") - - # --- deployments over time --- - rows.append(LBL + " ── ACTIVITY ── " + DIM + "deploys/hour, last 48h") - act, peak = activity(deps, w) - rows.extend(act) - if peak: - rows.append(seg([(DIM, " 48h ago"), - (DIM, " " * max(1, w - 22)), - (DIM, "peak %d/h" % peak)], w - 1)) - - durs = sorted(x for x in (build_seconds(d) for d in deps) if x) - if durs: - med = durs[len(durs) // 2] - p95 = durs[min(len(durs) - 1, int(len(durs) * 0.95))] - rows.append("") - rows.append(seg([(LBL, " ── BUILD TIME ── "), - (DIM, "median "), (TXT, dur(med)), - (DIM, " p95 "), (TXT, dur(p95)), - (DIM, " max "), (TXT, dur(durs[-1]))], w - 1)) - recent = [build_seconds(d) for d in deps[:max(10, w - 2)]][::-1] - recent = [x for x in recent if x] - if recent: - hi = max(recent) - spark = "".join(SPARK[min(7, int(x / hi * 7.99))] for x in recent) - rows.append(" " + READY + spark) - rows.append("") - - # --- recent deployments --- - rows.append(seg([(LBL, " ── RECENT ── "), - (DIM, "%d of %d" % (selected + 1, len(shown)) if shown else "")], - w - 1)) - cols = columns(w) - wide, single = cols["detail"], cols["single"] - per_item = 1 if single else 2 - visible = max(1, (h - len(rows) - 1) // per_item) - scroll = min(scroll, max(0, len(shown) - visible)) - if selected < scroll: - scroll = selected - elif selected >= scroll + visible: - scroll = selected - visible + 1 - for i in range(scroll, min(len(shown), scroll + visible)): - d = shown[i] - if len(rows) >= h - 1: - break - here = (i == selected) - tint = bg(28, 44, 62) if here else "" - meta = d.get("meta") or {} - state = d.get("state", "?") - col = STATE_COLOR.get(state, DIM) - mark = SPINNER[tick % len(SPINNER)] if state in ( - "BUILDING", "QUEUED", "INITIALIZING") else ( - "●" if state == "READY" else "✖" if state == "ERROR" else "○") - msg = (meta.get("githubCommitMessage") or "").split("\n")[0] - line = [(tint + col, ("▸" if here else " ") + "%s %-9s" % (mark, state.title())), - (tint + TXT, pad(d.get("name", "?"), cols["project"])), - (tint + DIM, dur(build_seconds(d))), - (tint + DIM, " %4s" % age(d.get("created", 0)))] - if d.get("target") == "production": - line.append((tint + PROD, " PROD")) - elif wide: - line.append((tint + DIM, " prev")) - if wide: - line.append((tint + SHA, " " + (meta.get("githubCommitSha") or "")[:7])) - line.append((tint + BRANCH, " " + pad((meta.get("githubCommitRef") or ""), - cols["branch"]))) - if single: - line.append((tint + (TXT if here else MSG), " " + msg)) - if here: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - if not single and len(rows) < h - 1: - rows.append(seg([(tint + (TXT if here else MSG), " " + msg), - (tint, " " * w if here else "")], w - 1)) - if not shown: - rows.append(DIM + " (nothing matches the current filter)") - - hints = [[(ACCENT, "↑↓"), (DIM, " select")], - [(ACCENT, "↵/[i]"), (DIM, " details")], - [(DIM, "[f]ilter")], [(DIM, "[p]roject")], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]] - footer = [" " + line for line in pack_hints(hints, w - 2)] - rows = rows[:h - len(footer)] - while len(rows) < h - len(footer): - rows.append("") - rows.extend(footer) - draw(rows, w, h) - time.sleep(0.25) - - -main() diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..edaaddb --- /dev/null +++ b/docs/README.md @@ -0,0 +1,36 @@ +# Documentation + +[← terminal-toys](../README.md) + +One page per widget: what it shows, where every number on it comes from, +each key it answers to, and the settings it reads. + +## Widgets + +| | | +|---|---| +| [`start`](start.md) | The front door: every widget, what it does, and whether it will work on this machine. | +| [`latency`](latency.md) | Continuous latency to a list of hosts, with the statistics that actually explain a bad connection. | +| [`deployments`](deployments.md) | Vercel deployments — how they are going over time, not just what shipped last. | +| [`tailnet`](tailnet.md) | Tailscale peers, and — the part plain `tailscale status` buries — *how* you are reaching each one. | +| [`herdr-panes`](herdr-panes.md) | Everything running under [Herdr](https://herdr.dev), across every workspace — and one keypress to get to any of it. | +| [`github`](github.md) | Pull requests across every org you work in — not what shipped, but whether work is actually moving. | +| [`pr`](pr.md) | The pull requests you have to follow up on, and a dashboard for whichever one you open. | +| [`linear`](linear.md) | Linear across every team at once — what is outstanding, which cycles are running, and whether issues are being closed faster than they arrive. | +| [`usage`](usage.md) | How much the coding agents on this machine have actually been used — one tab per agent, from each agent's own local state, plus a live quota reading for the four that publish one and a subscription for the five that do. | +| [`ports`](ports.md) | What is listening on this machine, what started it, and who can reach it. | +| [`netwatch`](netwatch.md) | Which processes are using the network, how much they have used, and how fast they are going right now. | +| [`link`](link.md) | How good the connection is between this machine and whoever is connected to it — measured, not probed. | +| [`clocks`](clocks.md) | This server's clock, the clocks counting down, a pomodoro, and everyone else's clock — the four things you need to know about time while working across timezones. | + +`matrix` has no page. It computes nothing on purpose, and a document +saying so at length would be the joke explained. + +## About the repository + +| | | +|---|---| +| [Design](design.md) | The rules every widget holds to, and what each one cost to learn. | +| [Internals](internals.md) | `toys-core`, the chart helpers, and what `cargo test` checks that a compiler cannot. | +| [Port decisions](port-decisions.md) | What the Rust port changed from the Python, and why. | +| [Building herdr panels](building-herdr-panels.md) | Driving these from Herdr: resize semantics, focus, and the layout mistakes worth skipping. | diff --git a/docs/building-herdr-panels.md b/docs/building-herdr-panels.md index ea61dec..6a391f9 100644 --- a/docs/building-herdr-panels.md +++ b/docs/building-herdr-panels.md @@ -1,5 +1,7 @@ # Building panels for Herdr +[← all docs](README.md) + Notes from building the widgets in this repo against [Herdr](https://herdr.dev), a terminal multiplexer for coding agents. None of this is in `herdr --skill`, which documents the CLI surface; this is what the surface does not tell you. diff --git a/docs/clocks.md b/docs/clocks.md index 23e78e7..b24173d 100644 --- a/docs/clocks.md +++ b/docs/clocks.md @@ -1,4 +1,6 @@ -# `clocks.py` +# `clocks` + +[← all docs](README.md) This server's clock, the clocks counting down, a pomodoro, and everyone else's clock — the four things you need to know about time while working across @@ -96,7 +98,7 @@ shown — are not tied to the day and survive. | `space` | pause / resume | | `s` `b` `e` | start a break during focus, end one during a break — the footer names whichever applies | | `r` | restart the current phase | -| `+` `-` | focus length, snapped to multiples of five. `=` works as `+`, so it needs no shift, and the footer writes the pair as `[±]` | +| `+` `-` | one minute on or off **whichever block is running** — focus, short break or long break — clamped to 1–180. The finish line moves by however much the block actually changed, so a minute added twenty minutes into a twenty-five minute block leaves six, not twenty-six. `=` works as `+`, so it needs no shift, and the footer writes the pair as `[±]` | | `0` `c` | zero today's completed tally | | `?` `h` | hide/show the pomodoro controls | | `q` | quit | diff --git a/docs/deployments.md b/docs/deployments.md index e987e6c..2588d2f 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -1,11 +1,13 @@ -# `deployments.py` +# `deployments` + +[← all docs](README.md) Vercel deployments — how they are going over time, not just what shipped last. ``` ╺━ VERCEL DEPLOYMENTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 200 deploys · 20 proj 179 ready 21 error 0s ago - ↑↓ select · [c]opy · [r]efresh [f]ilter [p]roject [q]uit + ↑↓ select · →/↵ details · [s]tate all · [/]filter · [r]efresh · [q]uit ── ACTIVITY ── deploys/hour, last 48h ····▂▂·▃▃▄▄···················▂▄▆▄▃▃▃▄▂▃▃············▂▃·▃▂▂▄·▂▄█▃▂▃ @@ -75,17 +77,72 @@ omitted, and the sheet says how many are missing rather than silently dropping them. Every URL is shown wrapped in full, so mouse selection still works where OSC 52 is blocked. +## The detail view + +`→` or `↵` opens one deployment in full. It scrolls with `↑` `↓` — the build +log below makes it taller than any pane — and the footer says where you are +in it. `PgUp` `PgDn` `Home` `End` move by the page and to either end. + +It refetches every minute while it is open, because a build that is still +running writes more log while you are reading it, and `r` asks again at once +rather than waiting for that. + +`c` opens the copy list on **its own page**, and `←` or `esc` brings you back +to the detail where you left it. The list used to sit at the bottom of the +detail, which was fine until the build log went in above it: the numbers you +press were then reliably scrolled off the screen you were pressing them from. + +## The build log + +The detail view ends with the deployment's own build output, fetched from +`/v3/deployments/{id}/events` on the same trip as the rest of the detail. +`errorMessage` above it says a build failed and names a code; the log says +which line of somebody's config did it, which is the thing you would +otherwise open a browser for. + +What the build wrote to **stderr** is drawn in the error colour among the +stdout lines, because on a failed build that is the one line worth finding +and it arrives among dozens that look alike. Long lines wrap rather than +clip — a stack trace cut at the pane edge is the half without the path in it. + +The last 200 lines are requested. A long build runs to thousands and the +pane shows a few dozen, so asking for all of them would spend the wait on +text nobody reads; the tail is the useful end, since a build explains itself +on the way out rather than on the way in. + ## Keys | Key | Action | |---|---| | `↑` `↓` `PgUp` `PgDn` `Home` `End` | move the selection | -| `Enter` / `i` / `c` | full detail view for the selected deployment | +| `→` / `Enter` | full detail view for the selected deployment | | `1`–`7` | inside the view, copy that item | -| `f` | filter — all / failed / production | -| `p` | cycle which project is shown | -| `r` | refresh now | -| `q` | quit | +| `←` / `esc` | close the detail view | +| `s` | state filter — all / failed / production | +| `/` | filter by text — `enter` keeps it, `esc` clears it | +| `c` | copy the selected PR's… (in the detail view, the copy page) | +| `r` | refresh now, and in the detail view fetch it again | +| `q` | quit, from either screen | +| `f` `p` | the state filter and the project cycle — **`deployments` only**; the Rust build has `s` and `/` instead | + +## Filtering + +Two filters, and they stack. + +`s` cycles the **state** — all, failed, production. It is a fixed set, so it +cycles rather than types. + +`/` filters by **text**, against everything the row shows and one thing it +does not: project name, state, target, branch, commit subject, and the +deployment id — the last because an id is what a link from somewhere else +gives you to look something up by. Type to narrow, `enter` to keep it and go +back to the arrows, `esc` to clear it. While typing, every key is text: `q` +types a q rather than quitting, which is the only way a filter can contain +one. + +This replaced a `p` key that cycled one project at a time. With a dozen +projects, reaching the last meant pressing it a dozen times, and it could +never express "the failed ones in either of these two". ## Layout @@ -137,6 +194,6 @@ Empty `teams` discovers every team you can see; empty `projects` shows all. Polling every 15s is 4 requests/min per team. ```sh -./deployments.py # every project, 15s -./deployments.py -n 60 my-project # one project, slower +./target/release/deployments # every project, 15s +./target/release/deployments -n 60 my-project # one project, slower ``` diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..98b8911 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,42 @@ +# Design conventions + +[← all docs](README.md) + +A few rules hold across all fourteen widgets. They are here rather than in +the README because they are for whoever changes one, not for whoever runs +one — and because each of them was paid for by something that shipped +wrong first. + +- **Spend extra width on more content, not padding.** Widgets add columns as a + pane grows and drop them as it shrinks, rather than truncating. +- **Never truncate a key hint.** Footers wrap across as many lines as they need + and never split a hint, because `[±]25` teaches a key that does not exist. +- **A directional glyph points the way the thing goes.** `▲`/`▼` mark which + half of a diverging chart a series occupies — `▲ opened` above the baseline, + `▼ merged` below it. `↑`/`↓` mean upload and download. Where both meanings + meet, in `netwatch`'s chart, the halves are arranged so they agree: tx + above and rx below, because a `↓` label over a line that climbs asks the + reader to hold two directions at once, and they will believe the arrow. +- **Measure contrast, do not eyeball it.** Every colour that draws text clears + WCAG AA against both the terminal background *and* the selected-row tint, + with the measured ratios recorded beside the definitions. +- **Say what a number means when it is not obvious.** Counters that reset with + a daemon, durations that predate the process, aggregates that hide their + outliers — each is labelled rather than left to mislead. +- **Never show a stale figure under a fresh label.** Change a setting and the + numbers it governs shimmer until real ones land, rather than sitting there + looking current. The same rule kills silent truncation: a chart that cannot + fit its window says `54d of 90d`, and a token missing a scope is named rather + than left to quietly undercount. +- **Optional enhancements, never requirements.** The clipboard goes through + OSC 52 so it survives SSH; Herdr toasts and `sudo`-gated data are added where + available and skipped silently where not. + +## Where these are enforced + +Four of them are not prose. `cargo test` runs +[`widgets/tests/check.rs`](../widgets/tests/check.rs), which reads the +sources and fails on a footer hint naming a key nothing answers, a hint +missing from the widget's doc, a config key read but never documented, +and a colour drawing text on the selected-row tint below WCAG AA. See +[internals](internals.md#the-checks). diff --git a/docs/github.md b/docs/github.md index a3a4395..68a28c3 100644 --- a/docs/github.md +++ b/docs/github.md @@ -1,4 +1,6 @@ -# `github.py` +# `github` + +[← all docs](README.md) Pull requests across every org you work in — not what shipped, but whether work is actually moving. @@ -196,10 +198,44 @@ into a single request returned HTTP 502 on the complexity limit. | Key | Action | |---|---| -| `↑` `↓` | select an account | +| `↑` `↓` | select an account — on an account's own screen, move through its oldest open PRs | +| `→` `↵` | open the selected account | +| `←` `esc` | back to the board | +| `c` | copy the selected PR's URL | +| `PgUp` `PgDn` `Home` `End` | scroll an account's screen by the page, or to either end | | `w` | cycle the window — 7 / 14 / 30 / 60 / 90 days | | `r` | refresh now, ignoring the day cache | -| `q` | quit | +| `q` | quit, from either screen | + +## One account on its own screen + +`→` or `↵` opens the highlighted account. Most of what is there the row +already carried and had no room to spell out — open split into what waits on +a reviewer and what is still a draft, merged split into what landed and what +was closed unmerged — plus a few figures worth deriving: + +- **net** — opened minus merged over the window. A queue of six hundred is a + different thing depending on whether it grew by forty this week or held + level. +- **merged/day**, with the open queue restated as time at that rate. *"110d + of open PRs"* is the number people estimate and get wrong. +- **busiest day** and **days with none** — the shape of the window. + +The **OPEN PR STATE** bar and the **PR FLOW** chart are the two the board +draws for every account added together, drawn here for one. That is the +reason to open the screen: a queue growing in a single account is invisible +in a total six others are also feeding. + +**OLDEST OPEN** lists the ten longest-waiting PRs, newest information the +board cannot hold. Everything else on this widget is built from `issueCount` +aggregates — exact at any volume, one rate-limit point per request rather +than per alias, and unable to name anything at all. So this one asks for +nodes, once per account when its screen is first opened, and keeps the +answer. + +`↑` `↓` move through that list and `c` copies the URL of the row under the +cursor, the same key `pr` uses for the same job. The page scrolls to follow +the cursor; `PgUp` `PgDn` move it freely for the sections above and below. ## Credentials @@ -256,16 +292,21 @@ file is git-ignored and the token is never printed. "token": "", "token_env": "GITHUB_TOKEN", "accounts": [], - "window_days": 7, + "window_days": 14, "refresh": 120 } ``` Empty `accounts` discovers every org you belong to plus your personal account; otherwise list org logins, and `@me` for your own. `window_days` sets the window -the board opens on; `w` cycles it from there. +the board opens on — **14 days by default** — and `w` cycles it from there +through 7 / 14 / 30 / 60 / 90. + +Fourteen rather than seven because a week is short enough that one quiet +Friday moves every figure on the board: a merge rate, a per-day average and +a queue trend all read as noise when a single day is a seventh of the sample. ```sh -./github.py # discovered accounts, 120s -./github.py -n 300 acme @me # two accounts, slower +./target/release/github # discovered accounts, 120s +./target/release/github -n 300 acme @me # two accounts, slower ``` diff --git a/docs/herdr-panes.md b/docs/herdr-panes.md index 738d947..412b8de 100644 --- a/docs/herdr-panes.md +++ b/docs/herdr-panes.md @@ -1,4 +1,6 @@ -# `herdr-panes.py` +# `herdr-panes` + +[← all docs](README.md) Everything running under [Herdr](https://herdr.dev), across every workspace — and one keypress to get to any of it. @@ -19,14 +21,14 @@ and one keypress to get to any of it. ── PROCESSES ── 7 panes running something COMMAND CPU MEM WORKSPACE - ▪ tailnet.py 3% 16M infra + ▪ tailnet 3% 16M infra ▪ pnpm 0% 116M some-cli ── IDLE ── 13 panes at a prompt ▫ work/site site ▫ …/another-monorepo monorepo - ↑↓ select ↵ switch to this pane [o]idle [w]labels [r]efresh [q]uit + ↑↓ select ↵ switch to this pane [i]dle [l]abels [r]efresh [q]uit ``` ## Why it is ordered this way @@ -51,6 +53,14 @@ monitors, builds — with the command, CPU and memory. **IDLE** lists panes at a shell prompt by directory, because the panel is also how you navigate: a shell sitting in a repo is somewhere you want to jump to even with nothing running. +There is a third answer, and PROCESSES carries it too. A pane the CLI could +not be asked about is neither running something nor resting: it sorts to the +top with `⚠ could not be read` and the reason on its own row, and the heading +counts it apart — `3 panes running something · 29 could not be read`. It is +kept visible on purpose, and `[i]` hides only panes known to be at a prompt, +because an unreadable pane filtered out of sight is exactly the shape this +widget exists to avoid: a failure that looks like an empty list. + ## How it knows Everything comes from the Herdr CLI, so this is a Herdr client rather than a @@ -68,20 +78,47 @@ with `herdr integration status`. **Idle panes are detected exactly**, not guessed: a busy pane's foreground pid differs from its own shell pid. Command names come from `argv`, so a pane shows -`tailnet.py` rather than `python3`. +`tailnet` rather than `python3`. **Durations are marked `≥`** when the state was already in place before the widget started — we did not see it begin, so it is only a lower bound. Herdr does not timestamp state changes, so transitions are tracked here. +## When it does not all fit + +The three lists read as one under the arrows, and the pane is a window onto +that one list. The header above them is pinned — the counts and the +`▸ N agents waiting for you` line are the reason to have the widget open, and +they never scroll away. + +The window follows the cursor: it holds still while the cursor moves inside +it, and moves by as little as it takes when the cursor would leave. It is +measured in **rows**, not entries, because an agent takes two rows — its +second carries the directory and the pane title — while a process takes one. +Counted in entries it admits more rows than the pane has, they are cut off the +bottom, and the cursor goes with them: it kept moving past the last drawn row +and disappeared, while `Enter` still switched to whatever it was invisibly +sitting on. + +A heading whose section is not all on screen says so — `── IDLE ── 15 panes at +a prompt · showing 4-15` — and one the window has scrolled clean past says +`none on screen` rather than standing over nothing, which reads as a section +that has failed to load. A section entirely on screen says nothing: a range on +a list you can see all of is noise. + +The idle heading is drawn whenever there are idle panes, at every height. It +used to be rationed — granted a heading only if the lists above had left room +— and dropping it silently left the footer offering `[i]dle` with nothing +behind it. + ## Keys | Key | Action | |---|---| -| `↑` `↓` `Home` `End` | select, across all three sections | +| `↑` `↓` `Home` `End` | select, across all three sections; the window follows | | `Enter` / `f` | **go there** — the agent's pane, or the tab holding that process | -| `o` | show/hide the idle section | -| `w` | workspace labels vs pane ids | +| `i` | show/hide the idle section — `o` in the Python, which is being retired | +| `l` | workspace labels vs pane ids | | `r` | refresh now | | `q` | quit | diff --git a/docs/internals.md b/docs/internals.md new file mode 100644 index 0000000..9445586 --- /dev/null +++ b/docs/internals.md @@ -0,0 +1,39 @@ +# Internals + +[← all docs](README.md) + +What is shared between the widgets, and what the test suite checks that a +compiler cannot. For the rules these implement, see +[design conventions](design.md). + +## The checks + +`cargo test` from the root runs each widget's tests plus +`widgets/tests/check.rs`, which reads the sources and fails on a poller that +dies without saying why, a footer or `--help` line naming a key nothing +answers, a hint missing from the widget's doc, a config key read but never +documented in `config.example.json` — or documented there and never read, or +read with no fallback behind it — and a colour that draws text on the +selected-row tint below WCAG AA. + +Every one of them exists because something shipped broken and looked, on +screen, exactly like "there is no data". + +## `toys-core` + +`toys-core` holds the shared pieces — terminal sizing, a full-frame `draw()`, +24-bit colour, a green→amber→red `heat()` ramp, `seg()` for clipping coloured +text to a cell budget, `pack_hints()` for wrapping footers, `follow()` for a +window that keeps a cursor in view, non-blocking `Keyboard` input with +arrow-key decoding, and `clipboard()` over OSC 52. + +The chart helpers are worth knowing before drawing anything new: `vbars()` and +its mirror `vbars_down()` (pair them on a shared scale for a diverging chart), +`stacked_bar()` for proportions, `meter()` for a gauge, and `skeleton()` for +the shimmer that stands in for a figure still being fetched. + +Braille line charts are not among them. `latency` and `link` each keep their +own `braille_canvas`, and the two are not the same function: latency's series +carries the gaps a ping can leave, and link's is told how many slots the axis +holds, so that a session younger than the chart takes its own share of the +width rather than being stretched across all of it. diff --git a/docs/latency.md b/docs/latency.md index 4050a4b..491aeed 100644 --- a/docs/latency.md +++ b/docs/latency.md @@ -1,4 +1,6 @@ -# `latency.py` +# `latency` + +[← all docs](README.md) Continuous latency to a list of hosts, with the statistics that actually explain a bad connection. @@ -23,6 +25,17 @@ explain a bad connection. 300s ago now ``` +## A host that will not answer, and a ping that will not run + +They are not the same thing and no longer look the same. A host that does not +reply is a result — it shows as loss, which is what the widget is for. A +`ping` this widget could not start is a failure of its own, and the row now +carries the reason and turns its name red, rather than sitting empty and +being retried every two seconds in silence for as long as the widget is up. + +The figures beside it stay. They were true when they were taken, and they are +the last thing that target was known to be doing. + ## Why the shape of it **The graph is log-scale**, because a useful target list spans three orders of @@ -108,8 +121,8 @@ baseline, not a path measurement. `studio` rather than clipping the interesting half. ```sh -./latency.py # config targets, 0.5s -./latency.py -i 2 1.1.1.1 example.com # override both +./target/release/latency # config targets, 0.5s +./target/release/latency -i 2 1.1.1.1 example.com # override both ``` It measures *this host → each target*. Target-to-target legs need a probe on the diff --git a/docs/linear.md b/docs/linear.md index 5c5d340..eacf460 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -1,4 +1,6 @@ -# `linear.py` +# `linear` + +[← all docs](README.md) Linear across every team at once — what is outstanding, which cycles are running, and whether issues are being closed faster than they arrive. @@ -91,7 +93,7 @@ decision. backlog / todo / in progress. Not windowed: it answers "how much is there right now", and does not move when you change the window. -**Active cycles** — every team's running cycle, from a single query. Progress +**Active cycles** — every team's running cycle, walked like everything else; one request while they fit a page. Progress bar, points completed against scope, and days remaining. `+175 added` is the number to watch: scope added *after* the cycle opened. A @@ -110,8 +112,159 @@ down, one bar per day, both directions on a shared scale. Read together they say whether the queue is filling faster than it drains. In the board above, 297 created against 88 completed. -**By team** — ranked by open volume, scrolling when focused and there are more -teams than rows. `DONE14D` follows the window. +**By team** — ranked by open volume. `DONE14D` follows the window the header +names. + +**Projects** — every project still going, whichever team owns it, running work +first. One shared between teams shows both keys (`OPS/WEB`) and appears once. + +``` + ── PROJECTS ── 34 running · 9 finished, not listed ↑↓ +▸ OPS uptime-monitoring Maintenance █████████████ 100% + WEB checkout-rewrite In Progress █████████░░░░ 72% 110 open + OPS/WEB storage-provider-swap Paused ░░░░░░░░░░░░░ 3% 9 open + CLI offline-mode Idea ░░░░░░░░░░░░░ 0% 25 open +``` + +Finished and cancelled projects are not on the board - the heading says how +many, because "34 running" alone reads as a count of all of them. They are all +still on their team's own screen. `↵` here opens a project directly, without +going through its team. + +## One cycle, or one team + +`↵` or `→` on the highlighted row opens it on a screen of its own; `←` or `esc` +comes back; `↑` `↓` and `PgUp` `PgDn` scroll it when it is longer than the pane. + +A **cycle** gives its progress, its scope and how much of it is closed, how long +is left to run, and how much moved lately — the same day-over-day figure the +board ranks cycles by, so the ordering is legible rather than mysterious. A +cycle with no name of its own is called by its number rather than left blank. + +Under the burn-up, what is open in it by state, and then the issues themselves: + +``` + ── OPEN IN THIS CYCLE ── 28 · 5 closed, not listed +▸ WEB-275 in progress Server-rendered context blocks on top of the share copy 19.3d 1p + WEB-300 in progress Check every published record against its upstream source 16.0d + WEB-229 todo Add an itinerary mode to the card renderer 22.1d 4p +``` + +What is moving comes first, then what is waiting, and inside each the oldest +first — so the row at the top is the one that has been in progress longest. An +issue nobody has pointed shows no points rather than `0p`. A title too long for +its column carries on underneath rather than being cut: an issue cut to forty +characters is a different issue on screen. + +**Only the open ones are listed**, because open issues are all this widget ever +fetches — so the heading says how many are closed. Without that, a cycle of +eight showing one row reads as a cycle of one. + +The arrows move a cursor through them and `[c]opy url` puts the selected +issue's address on the clipboard, over OSC 52, the same way the pr widget does. +An issue is a leaf here: there is no screen below it, so what it offers is +somewhere to go and read it. + +A **team** gives what it is holding, broken out by state as a stacked bar, with +triage called out separately: it is work nobody has looked at, and a team can +hold hundreds of it while looking busy everywhere else. + +Under that, **every project the team owns**: + +``` + ── PROJECTS ── 6 · 672 open in no project + uptime-monitoring In Progress ███████████████ 100% 10 open + search-relevance In Progress █████████████░░ 88% 1 open · A Lead + storage-provider-swap Paused █░░░░░░░░░░░░░░ 3% due 2026-04-10 · A Lead + offline-mode Idea ░░░░░░░░░░░░░░░ 0% 25 open · A Lead + runtime-metrics Maintenance ███████████████ 100% A Lead + cluster-migration Completed █████████████░░ 87% 2 open · due 2024-07-26 · A Lead +``` + +`↑` `↓` move a cursor through that list and the screen scrolls to follow it, +the same way the board does; `→` or `↵` opens the project under the cursor. + +Running work sorts first and finished work last, with a status this build has +never heard of sorting *with* the live work rather than under the dead work — +a workspace names its own statuses, and burying an unfamiliar one would hide +real work. The status is shown by the workspace's own name for it — `In +Progress`, not `started` — and its type picks the colour. + +The percentage is **Linear's own published `progress`**, not a figure derived +here. The board fetches only what is open, so a project that is finished has +nothing left for this widget to count; taking Linear's number is the only way +the two agree. That is also why a project can read 87% and still show open +issues, and why the per-project counts do not sum to the team's open total — +which is what `672 open in no project` is there to say. Without it a column of +numbers sits three lines under a larger one and reads as a bug. + +Projects are shared: one owned by two teams appears on both screens. + +Columns are sized to what is in them — no name is ever cut, because half a +project's name is a name for something else. The bar takes whatever is left, +and when the pane is too narrow the aside sheds whole facts off the end rather +than let one be cut in half. + +## One project + +`→` or `↵` on a project opens it. `←` or `esc` comes back to the team it was +opened from, not to the board — one level at a time. + +``` +╺━ CLUSTER MIGRATION · OPS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ + progress 87% █████████████████████░░░ + status Completed + scope in points 144 102 done, 42 left + issues 38 31 closed + scope added +3 since it started + started 2024-06-25 + completed 2026-05-10 + target was 2024-07-26 + lead A Lead + members 1 A Lead + initiative Infrastructure + oldest open 2.2y OPS-37 + + ── OPEN BY STATE ── 2 issues + ████████████████████████████████████████████████████████████████████████████████ + ▇ backlog 1 (50%) ▇ todo 1 (50%) + + ── MILESTONES ── 3 + Workload Migrations ██████████████████████████████ 100% 2024-06-26 + SSL Optimisations ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% 2024-06-28 + Cost and Performance Optimisations ████████████████████░░░░░░░░░░ 67% 2024-07-26 + + ── WHAT IT IS FOR ── + Move every workload off the old cluster and onto the managed one, and + retire the old one once nothing is left on it. +``` + +Three measures sit next to each other and none of them is the others: Linear's +own weighted `progress`, the points, and the issue count. **Scope is labelled +in points** for that reason — unlabelled, 87% above `102 done` of 144 above +`31 closed` of 38 reads as one measure got wrong three times. + +`overdue by` appears only while the work is still running. A project that +finished after its target date is not late, and used to read `overdue by 760d` +because nothing was asking whether the work had since landed; a finished one +shows when it completed and what its target *was*. + +Milestones are ordered by target date, undated ones last — Linear returns them +in no order at all. Their `progress` is reported out of a hundred where a +project's is out of one, so it is divided before it reaches a bar; fed straight +in, every milestone read full. + +`── OPEN BY STATE ──` and `oldest open` come from the board's own pass over +every open issue, so they cost nothing and stay current while the screen is up. +They count the project across *every* team that shares it, because the screen is +about the project rather than about whichever team's list it was opened from. + +Everything else — the burn-up, the milestones, the members, the description — is +one request made when the screen opens, and again when what it fetched is older +than the refresh interval. Fetching that for every project in the workspace +every two minutes would be paying, continuously, for screens nobody has opened. +While the request is out the screen says so; if it fails it says that instead, +rather than reading "loading" for ever. ## Cost @@ -121,7 +274,10 @@ multiplied by the page size, so the request count is the limit that binds and the field count barely matters. A full pass over a workspace of 14 teams and ~1,200 open issues costs about -**10 requests and 4 seconds**, so the default 120s refresh uses roughly 300 +**11 requests and 4 seconds** — one of them the whole workspace's projects, plus +one more each time a project's own screen is opened, +fetched with everything else so a team's screen opens on data already in hand +rather than showing nothing while a request goes out, so the default 120s refresh uses roughly 300 requests an hour — an eighth of the budget. Remaining quota is read from `X-RateLimit-Requests-Remaining` and shown in the header. @@ -130,16 +286,59 @@ paged through at 250 records a time. Pagination is capped at 12 pages per query and the header says `truncated` when the cap is reached, rather than quietly reporting a smaller number. +The teams and the active cycles are walked the same way. A cycle walk that +hits the cap makes the heading read `at least 600 running` rather than `600 +running`. Two nested connections are marked rather than walked, because +walking them would re-fetch a whole project record per page to settle a +question that is almost always already settled: a project's `members` row +reads `21+` with a trailing `…`, and its MILESTONES heading `25+`, when +Linear says the page it returned was not the last. Those rows show a page, +not necessarily everything. + ## Keys -Two sections scroll — the cycles and the team table — so the arrows need to -know which one they are in. `tab` moves the focus, and the focused heading says -so by carrying the `↑↓` marker and its visible range. +The board is longer than most panes are tall, and **every section is drawn +whole** - the pane is a window onto it. So the arrows do one of two things, and +the footer says which. + +The board opens with no cursor anywhere - it is a thing to read before it is a +thing to work - and there the arrows and `PgUp` `PgDn` move the window: the way +to read the whole board without picking a section first. + +`tab` focuses a section, and the focused heading says so by carrying an arrow +marker. The arrows then move a cursor through that section, and the window +follows it - a cursor below the fold pulls the board down, one above it pulls +it up, each by as little as it takes. + +Exactly one *other* heading carries `[tab] to focus`, and it is the one tab +would actually focus next. Not all of them: tab cycles, so a `[tab]` on every +heading would promise three sections that one press reaches when one press +reaches one of them. As you press it the marker moves down the board, which is +the cycle teaching itself. A section with nothing in it never carries it, +because tab steps over those. + +Under the arrows the three sections read as **one continuous list**: down off +the bottom of the cycles steps into the top of the teams, off the bottom of the +teams into the top of the projects; up steps back the same way, into the +*last* row of the section above. `tab` is the shortcut across a whole section +rather than the only way between them. + +You let go at exactly two places: up from the first cycle, and down from the +last project. `tab` from the last section does the same, and hands the arrows +back to the board. Sections with nothing in them are stepped over in every +direction. + +**This is the same rule in every widget here that has focusable sections.** | Key | Action | |---|---| -| `tab` | move focus between the cycles and the team table | -| `↑` `↓` | scroll the focused section | +| `tab` | focus the next section, and from the last one back to no focus | +| `↑` `↓` | scroll the board — or, with a section focused, move its cursor, crossing between sections at their ends | +| `PgUp` `PgDn` | the same, a page at a time | +| `↵` `→` | open the highlighted cycle, team or project — and from a team, the project under its cursor | +| `←` `esc` | back one level: a project to wherever it was opened from, a team to the board | +| `c` | copy the selected issue's url, on a cycle's screen | +| `r` | refresh, including the open project's own record | | `w` | cycle the window — 7 / 14 / 30 / 60 / 90 days | | `r` | refresh now | | `q` | quit | @@ -176,6 +375,6 @@ dropping it is sometimes the difference between a readable board and one number swamping the rest. ```sh -./linear.py # every team, 14-day window -./linear.py -n 300 WEB APP # two teams by key, slower +./target/release/linear # every team, 14-day window +./target/release/linear -n 300 WEB APP # two teams by key, slower ``` diff --git a/docs/link.md b/docs/link.md index 6177ed0..b597f25 100644 --- a/docs/link.md +++ b/docs/link.md @@ -1,4 +1,6 @@ -# `link.py` +# `link` + +[← all docs](README.md) How good the connection is between this machine and whoever is connected to it — measured, not probed. @@ -23,7 +25,7 @@ it — measured, not probed. ## Why this is not the latency monitor -`latency.py` measures paths it was told to measure, by sending pings. This one +`latency` measures paths it was told to measure, by sending pings. This one measures the path **you are on**, and sends nothing at all. Every established TCP connection has a kernel that has been timing it since it @@ -120,6 +122,7 @@ are still true about the path, just not about the terminal. |---|---| | `↑` `↓` | select a session | | `↵` / `i` | open that connection on its own screen | +| `n` / `p` | on the detail screen, step to the next or previous connection | | `esc` | back to the list | | `w` | cycle the chart's span: 1m, 5m, 15m, 1h | | `o` | hide or show sessions idle over five minutes | diff --git a/docs/netwatch.md b/docs/netwatch.md index e4df3dc..23e8671 100644 --- a/docs/netwatch.md +++ b/docs/netwatch.md @@ -1,4 +1,6 @@ -# `netwatch.py` +# `netwatch` + +[← all docs](README.md) Which processes are using the network, how much they have used, and how fast they are going right now. @@ -181,6 +183,15 @@ interfaces actually moved. When they agree, the list below is the whole picture. When they do not, the difference is traffic passing through, and the percentage says how much of the story the table is telling. +There is a second way the table can be showing less than everything, and it +says so too. When there are more processes than rows, the header adds +**`showing 1-14`** beside the count, and the table follows the cursor rather +than staying at the top of the list — so `↑` `↓` scroll it, and the selected +row is always on screen. Without the window the cursor walked off the bottom +of a short pane and disappeared, while `→` still opened whatever it was +invisibly sitting on. The label is absent when every process fits, because +then there is nothing to say. + Only real interfaces are counted — loopback, `tailscale0`, `docker0`, bridges and veth pairs are skipped, because a forwarded packet leaves through a card as well and counting both would count it twice. Which ones were counted is @@ -287,32 +298,69 @@ it as a machine honestly can: ── PROCESS ── command curl -s -o ~/tmp/big.bin --limit-rate 400k https://…/__down directory ~/projects/terminal-toys - started 6s ago - ── TALKING TO ── 1 connection - 162.159.140.220 https ↓ 3.2 MB ↑ 722 B 411.2 KB/s + ── TALKING TO ── 1 endpoint + HOST PORTS RX TX RATE + 162.159.140.220 https ↓ 3.2 MB ↑ 722 B 411.2 KB/s + + ── CONNECTIONS ── 2 sockets + SOCKET LOCAL STATE RX TX + 162.159.140.220:443 50206 open ↓ 3.2 MB ↑ 722 B + 162.159.140.220:443 43738 open ↓ 1.1 MB ↑ 310 B - ── WRITING TO ── where a download would be landing - ~/tmp/big.bin 3.0 MB +425.8 KB/s + ── FILES ── 1 file + PATH SIZE GROWTH + ~/tmp/big.bin 3.0 MB +425.7 KB/s ── DISK ── read 0 B · written 3.0 MB since it started - HTTPS hides the URL and the filename. Who it talks to and what it writes - are above. + HTTPS hides the request path and the remote filename — not the local file + it is writing, which FILES names above, nor who it is talking to. ``` -Three lists, and `tab` moves between them — the focused one is marked `▏` and -takes the arrow keys, and `c` copies whatever is selected in it. +Every list is drawn in full, always, and `↑` `↓` scroll the screen. `tab` +focuses a list instead: the focused one is marked `▏`, `↑` `↓` then move a +cursor `▸` inside it, and `c` copies whatever that cursor is on. + +Exactly one *other* heading carries `[tab] to focus`, and it is the one tab +would actually focus next — not all of them, because tab cycles and a `[tab]` +on all three would promise three lists that one press reaches. This heading +used to name a key per list, `[e]` and `[f]`, pointing at keys that no longer +existed; tab was the only one that was ever true, so tab is what is left, and +only where it is true. A list with nothing in it never carries it, because tab +steps over those. + +Under the arrows the three lists read as **one continuous list**: `↓` off the +bottom of a list steps into the top of the next, and `↑` off the top steps +into the *bottom* of the one above — the row you were about to reach if they +had never been separate. `tab` is the shortcut across a whole list rather +than the only way between them. + +You let go of the lists at exactly two places: `↑` from the very first row, +and `↓` from the very last. Both put you back to scrolling the screen, as +does `tab` from the last list. Lists with nothing in them are stepped over in +every direction, since there would be nothing to put the cursor on. + +**This is the same rule in every widget here that has focusable sections.** **TALKING TO** ranks the remote hosts by what they have carried since launch. Hosts, not sockets: a process opening six connections to one CDN is one thing being talked to. Peers resolve to names in the background — the address shows -until the answer arrives, and a lookup is never made twice. The highlighted -host gets its own small rx/tx chart, which is the quickest way to see whether -it is the one doing the work. +until the answer arrives, and a lookup is never made twice. The row the cursor +is on gets its own small rx/tx chart, drawn directly beneath it, which is the +quickest way to see whether that host is the one doing the work. **CONNECTIONS** is the sockets themselves, open right now, which is a different question: one host may hold six of them, and a socket that has -closed still shows what it carried. +closed still shows what it carried. It charts the same way TALKING TO does — +the cursor's socket gets an rx/tx chart under its row. + +**LOCAL** is the port at this end, and it is the only column that tells those +six apart. Five sockets to one CDN all read `1.2.3.4:443`, because the +address and port shown are the *peer's* and the peer's port is 443 on every +one of them; what differs is the port here. Without it the list is five +identical lines and "why are there so many of these" has no answer on screen. +The name is `ss`'s and `netstat`'s own — both label that column *Local +Address:Port*. A socket seen before the port could be read shows `-`. A hostname is a best-effort label rather than the domain that was asked for. CDNs, shared addresses, encrypted DNS and connection reuse all mean one @@ -361,17 +409,25 @@ Decimal — `KB` is 1,000 bytes, `MB` is 1,000,000 — which is how link rates a data caps are quoted, and therefore what these numbers are usefully compared against. +Every **rate** — `NOW`, `DOWN`, `UP`, the per-endpoint and per-socket figures, +and a file's growth — is an average over the last **ten seconds**, not over +the one-second sample. Almost all traffic is bursty, so an instantaneous rate +is honest and unreadable: a process that is steadily busy flickers between a +figure and a dash. An average over a stated window is just as true and can +actually be read. The header says the window (`every 1s · rates over 10s`) so +the number is never a mystery, and the **totals** are untouched by it. + ## Keys | Key | Action | |---|---| -| `↑` `↓` / `j` `k` | select a process — or an item within the focused section | +| `↑` `↓` / `j` `k` | select a process in the list — on the detail screen, scroll it, or move the cursor inside a focused section | +| `PgUp` `PgDn` `Home` `End` | move the detail screen by a page, or to either end | | `↵` / `→` | open the selected process | | `esc` / `←` | back to the list | -| `tab` | cycle the focused section: endpoints → connections → files | -| `e` | focus the endpoints | -| `f` | focus the open files | +| `tab` | focus the next section, and from the last one back to scrolling | | `c` | copy the selected host, socket or path | +| `e` `f` | jump straight to the endpoints or the files — **`netwatch` only**; the Rust build reaches every section with `tab` alone | | `s` | switch sort mode (`t` also works) | | `o` | show or hide processes you do not own | | `1` | sort by total data used | @@ -382,7 +438,7 @@ against. ## Options ``` -netwatch.py [-i SECONDS] [-n COUNT] [--sort total|live] [--external] [--plain] +netwatch [-i SECONDS] [-n COUNT] [--sort total|live] [--external] [--plain] ``` | Option | Meaning | diff --git a/docs/port-decisions.md b/docs/port-decisions.md new file mode 100644 index 0000000..f5d2cc3 --- /dev/null +++ b/docs/port-decisions.md @@ -0,0 +1,149 @@ +# What the port changed, and why + +[← all docs](README.md) + +For the length of the port every widget here existed twice, and the Rust was +never a transliteration: some of it answers differently on purpose. Telling +*on purpose* from *a defect the port introduced* was the whole point of the +side-by-side review ([TOY-8](https://linear.app/stealth-company/issue/TOY-8)), +and this page is what that review produced. + +**The Python is gone.** This page outlives it, because most of what is here +is not a comparison — it is the reason a key is the key it is, the reason a +rate is averaged, the reason a braille cell belongs to one trace. The Python +is how those reasons are explained, not why they matter. + +Everything below is in the past tense on purpose: it describes a thing that +was here, against which these decisions were made. `git log` still has it, at +`22a9bc7^`, if a claim here ever needs checking. + +**Reviewed against `ac02b90`.** Verified by reading both sources, not by +diffing them: three attempts at a mechanical key-differ each reported +something different and each was wrong in its own way — one missed +`if key == "f"` because it only read match arms, one counted `for key in +("send", ...)` as a keyboard key, and one missed `key in ("enter", "right", +"i")` because it only looked at the first element. That is the usual lesson +here: a grep that finds nothing is as often a wrong pattern as an absent +thing. + +## One way in, and one way out + +Seven widgets have a drill-in view and no two of them agreed on how to reach +it. `i` opened one in three of them, `c` opened another, `↵` alone opened +two, and coming back was `esc`, or `backspace`, or `q`, or `↵` again. + +**The Rust: `→` or `↵` in, `←` or `esc` out, everywhere.** No letter is +spent on it. + +Which means the Rust deliberately does *not* answer: + +| Key | Still opens a detail view in | Replaced by | +|---|---|---| +| `i` | `ports.py`, `link.py`, `netwatch.py`, `start.py`, `tailnet.py` | `→` / `↵` | +| `backspace` | `ports.py`, `netwatch.py` | `←` / `esc` | + +`i` was never in any footer in the Python either, in any of the five. It +worked and nothing said so. + +`q` quits from inside a detail view rather than closing it. In the Python it +closes the overlay in four widgets while the footer beside it reads +`[q]uit` — the key disagreeing with its own hint, and quietly, because the +widget stays up. + +**`usage` is untouched.** Its `←` and `→` move between vendor tabs, which is +lateral rather than into anything. + +**`pr` is a special case.** Inside its detail `↵` walks the stack, so `→` +drills further rather than closing, and `esc` keeps its second job of +clearing the search, which `←` has no business doing. + +## Sections are focused with tab, not with a letter each + +`netwatch.py` gives each of its three lists a key: `e` focuses endpoints, +`f` focuses files, `tab` cycles. The Rust has **only `tab`**, plus the rule +every widget here with focusable sections now follows: the sections read as +one continuous list under the arrows, crossing at their ends, letting go at +exactly two places. + +The heading of the section `tab` would focus *next* carries `[tab] to +focus`. The Python's headings carry the per-section letters instead. + +## Keys renamed, and why + +| Widget | Python | Rust | Why | +|---|---|---|---| +| `tailnet` | `n` cycles the interval | `i` cycles the interval | `i` is what `latency` calls the same thing, and `i` was free once the info screen moved to the arrows. `n` was this widget's own letter and meant nothing to a reader coming from the widget beside it. | +| `deployments` | `f` filters by project | `s` filters by state, `/` filters by text | Two different filters wanted the same letter. The one you type is `/`, as everywhere else that has one. | +| `herdr-panes` | `o` | `i` | Named after what it toggles. | + +`clocks`, `latency` and `deployments` also answer `j` and `k` as aliases for +`↓` and `↑` in the Rust only. + +## Charts + +**`latency` and `link` draw on a braille canvas** in the Rust, the way +`netwatch` always has — two dots to a character across and four down, with +consecutive samples joined. The Pythons plot one glyph per sample and fill +`│` between the steps, so a value that moves quickly reads as a column of +marks rather than a line. The side effect worth having is resolution: a cell +that used to hold one sample holds two. + +**A braille cell belongs to one trace.** Both Pythons merge every series' dot +masks into each cell and give the cell to whichever series comes later in the +table. A cell can hold two traces' dots but only one colour, so where two +hosts sit close together on the axis one is drawn end to end in the other's +colour and a third can vanish as a distinct line. No number is false; the +colour saying whose it is, is. The Pythons were never fixed — see below. + +**`netwatch` averages rates over about four seconds** in the Rust. Over one +sample interval the delta really is zero whenever a bursty process is between +bursts, so the column flickered between a figure and a dash — every reading +correct and the column unreadable. The header names the window. Totals are +untouched: smoothing a rate is honest, smoothing a total would not be. +`netwatch.py` was never fixed — see below. + +## Columns + +| Widget | Difference | +|---|---| +| `netwatch` | The connection list has a **LOCAL** column in the Rust — our end of the socket. Without it, several connections to one host and port are identical rows, and the only field telling them apart is not on screen. | +| `ports` | **WHAT** is sized to the widest name it has to show, in both, since the review; it was a flat eighteen cells with nothing after it and a name of exactly that length ran into the project name. | + +## Rust-only features + +Built after the port, on the Rust side only, because that is where the work +went once the ports were accepted: + +| Widget | What | +|---|---| +| `linear` | A screen of its own for a cycle, a team, and a project; the board scrolls as a whole; a PROJECTS section; `[c]opy url` on a cycle's issues | +| `ports` | Per-port traffic — a column, a per-row sparkline, a chart on the port's screen and one across the top | +| `deployments` | Build logs, a scrolling detail screen, `[/]` filter, copy on its own page | +| `github` | A per-account screen with the oldest open PRs and `[c]opy` | +| `matrix` | Answers `q`. `matrix.py` has no keyboard at all and exits on Ctrl-C | + +## Where the Rust deliberately agrees + +Worth writing down because it was checked rather than assumed: subprocess +timeouts are the Pythons' own numbers, config defaults were compared key by +key against the Pythons', and `ports`' program-name table matches the +Python's patterns — including the two that have to be anchored, which the +port had flattened to substring tests until the review caught it. + +## Two bugs that leave with the Python + +Both were found by this review, both are fixed in the Rust, and neither was +ever fixed in the Python — there was no reason to, once it was going. + +**A braille cell showed one trace in another trace's colour.** `latency.py` +and `link.py` merge every series' dot masks into a cell and give the cell to +whichever series comes later in the table. Where two hosts sit close together +on the axis, one is drawn end to end in the other's colour and a third can +vanish as a distinct line. No number was false; the colour saying whose it +was, was. This is the founding-rule break that started the review, and it was +found by eye — *why does that host have two lines* — after every test had +passed on it all day. + +**`netwatch.py`'s rate column flickers** between a figure and a dash, because +over one sample interval the delta really is zero whenever a bursty process is +between bursts. Every reading correct, the column unreadable. diff --git a/docs/ports.md b/docs/ports.md index 2074597..55e5cb7 100644 --- a/docs/ports.md +++ b/docs/ports.md @@ -1,4 +1,6 @@ -# `ports.py` +# `ports` + +[← all docs](README.md) What is listening on this machine, what started it, and who can reach it. @@ -79,6 +81,105 @@ pointing at it and nothing behind it — the URL exists, answers 502, and nothing in `lsof` explains why. It gets its own row rather than being left out for lacking a socket. +## Traffic + +The **TRAFFIC** column, and the chart on a port's own screen, come from the +kernel's own per-socket byte counters — `bytes_sent` and `bytes_received` out +of `ss -tine`, the same two fields netwatch reads. Nothing here is derived +from a guess. + +A *listening* socket carries no bytes. The traffic is on the connections +accepted from it, so a port's figure is the sum over every established socket +whose **local** port is that one. Only ports something is actually listening +on are tallied: most established sockets are outbound, and their local port is +an ephemeral number that belongs to nothing. + +``` + ── TRAFFIC ── ↑ out above · ↓ in below · 44s of history, sampled every 1s + █ ▁ + █▆ ▃ ▆▄▁▂ ▁█▁▁ ▁▂▇▄▁ +↑ 4.7 MB/s ··································██▅█▂ ▄▂▇████▅ ▃▂████▄▂▂▄█████▇ + ───────────────────────────────────────────────────────────────── + ·································· +``` + +The chart is as wide as the pane. One column is one sample, newest at the +right, and each direction is scaled to its own peak and says what that peak +was in a gutter down the left. A shared scale would flatten the quieter of the +two into nothing, and nothing is what a source with no traffic looks like — +the one reading this widget must never produce by accident. + +The dots are where there is no history yet. Left blank they would be +indistinguishable from a stretch of real zeroes, and a quiet port and an +unmeasured one are not the same thing. They fill in from the right as the +samples arrive, and once the history is longer than the pane is wide the +chart shows the most recent of it — which is what the heading's `44s of +history` counts, not everything kept. + +Three things it is honest about: + +**It counts what moved between two samples,** so a socket has to be seen twice +to say anything. A connection that opens and closes inside one interval is +never counted, and a long-lived one loses its last few bytes when it closes. +At the default four-second poll that is a real gap; `-n 1` narrows it. + +**Rates divide by the gap that actually happened,** not by the interval that +was asked for. `[r]` polls early, and a rate measured against the nominal +interval would read high every time somebody pressed it. + +**It is TCP.** A port serving anything else reads as quiet, because these +counters exist only for TCP sockets. Ports whose process belongs to another +user are counted the same as any other — the byte counters need no privilege, +even where naming the process does. + +Unlike netwatch, nothing is filtered by peer. netwatch drops loopback because +it is about what leaves the machine; here loopback is the whole point, since a +browser hitting a dev server on `127.0.0.1` is the traffic being asked about. + +The same chart is drawn once across the top of the main screen — everything +moving through every listening port — when there are rows to spare after the +table. The table is what this widget is for, so the chart yields to it. + +Beside the rates, each row carries **the shape** of its own traffic: + +``` + PORT BIND WHAT PROJECT TRAFFIC LAST 17s UP EXPOSED + 38611 local node a-project ·····───────── 10s - + 39311 all Python serve ↑503K ▆▃▁ ▂▂▄▇▅█▃▃▁▁ 4m - +``` + +Each row is scaled to **its own peak**, not to the busiest port on screen. A +shared scale would flatten every quiet port to nothing, and nothing is what a +port with no traffic looks like. So the shape column says *shape* and the +rates beside it say *size*, and the two are read together — a row with a full +bar and `↑2K` is a port at its own busiest, which is not busy. + +The three states are kept visibly apart, the same way the chart keeps them: +dots for cells with no sample behind them yet, a flat line for measured and +quiet, bars for traffic. `·····─────────` is a port that appeared ten seconds +ago and has done nothing since. + +Both columns arrive when there is room for them *after* the names, rather than +past some width picked in advance: the project column is the one that gives, +and a project's name cut in half is a different project. Both are measured +against a row that already carries UP and EXPOSED whether or not the pane is +yet wide enough to show them, so that crossing that width cannot trade one +fact for another. The shapes are the more decorative of the two and so arrive +last and leave first. A port nothing is calling shows nothing in the rates +column rather than `0 B/s` — a column of zeroes down the table reads as a +measurement that has failed. + +## When the scan itself breaks + +If the poller stops, the header carries `! poller stopped - see the pane it +was started from` and the table holds whatever it last knew. It used to catch +the failure and return an empty list, so the pane read `0 listening` — a +machine with nothing running on it, which is a thing this widget is supposed +to be able to say truthfully. + +The traffic sampler is separate: if it stops, the columns it feeds go quiet +and the line says so, while the table below carries on being found. + ## What it cannot see Sockets owned by another user, which on a normal machine means everything root @@ -148,7 +249,7 @@ never a dev server. Neither case is offered a prompt. ## The second screen -`↵` opens the selected port, and only when there is something behind it — a +`↵` or `→` opens the selected port, and only when there is something behind it — a process of yours, or a port Tailscale is already serving. Another user's socket does not get a screen, because the four columns already carry everything `/proc` will say about it, and a press that opens a repeat of the @@ -288,7 +389,7 @@ are on this screen rather than an IP. | Key | Action | |---|---| | `↑` `↓` | select a row — or an address, on the second screen | -| `↵` | open the selected port, where there is more to show | +| `↵` `→` | open the selected port, where there is more to show | | `esc` | back to the list | | `c` | copy the highlighted address | | `s` | `tailscale serve` this port, or stop serving it | @@ -303,9 +404,14 @@ are on this screen rather than an IP. ## Cost Nothing measurable. `/proc/net/tcp` and a walk of `/proc/*/fd` every four -seconds, plus one `tailscale serve status` — no network, no root, no -dependency beyond Tailscale for the exposure column, which is simply blank -without it. +seconds, one `ss -tine` for the byte counters, plus one `tailscale serve +status` — no network, no root, no dependency beyond Tailscale for the exposure +column, which is simply blank without it. + +The traffic sampling rides that same poll rather than a thread of its own. A +second thread would buy a finer chart and would also have to be watched: a +poller that dies is invisible, and its pane is indistinguishable from a source +with nothing to say. ## Configuration diff --git a/docs/pr.md b/docs/pr.md index 8a2f0b9..9ea1f90 100644 --- a/docs/pr.md +++ b/docs/pr.md @@ -1,4 +1,6 @@ -# `pr.py` +# `pr` + +[← all docs](README.md) The pull requests you have to follow up on, and a dashboard for whichever one you open. @@ -93,7 +95,7 @@ diff. Heights are **linear against the oldest PR**, which is worth knowing when the spread is wide: with an outlier at 3.9 years, everything under a couple of -months lands on the same lowest block. `latency.py` solves the same problem +months lands on the same lowest block. `latency` solves the same problem with a log scale; this chart has not adopted one yet. ## Which PRs, and why it takes three searches @@ -283,7 +285,7 @@ one you open — so the view paints a loading shimmer and fills in. **Reuses `github.token`** from `config.json`, or `$GITHUB_TOKEN`. No second credential: it is the same classic token with `repo` and `read:org` that -`github.py` uses. Set `pr.token` only to point this widget at a different +`github` uses. Set `pr.token` only to point this widget at a different account. ## Configuration @@ -303,9 +305,9 @@ account. Add, remove or rename sources freely — `review-requested:@me` and `is:open is:pr org:acme` are both reasonable entries, and the names are what `f` cycles through. Anything on the command line is appended to *every* source, -so `./pr.py org:acme` narrows the lot without editing config. +so `./target/release/pr org:acme` narrows the lot without editing config. ```sh -./pr.py # everything you are involved in -./pr.py -n 120 review-requested:@me # only what is waiting on your review +./target/release/pr # everything you are involved in +./target/release/pr -n 120 review-requested:@me # only what is waiting on your review ``` diff --git a/docs/start.md b/docs/start.md index b311f8a..35fab39 100644 --- a/docs/start.md +++ b/docs/start.md @@ -1,4 +1,6 @@ -# `start.py` +# `start` + +[← all docs](README.md) The front door: every widget, what it does, and whether it will work on this machine. @@ -15,7 +17,7 @@ machine. usage How much the coding agents have been used… reads what is logged in … - ── NETWATCH ── python3 netwatch.py + ── NETWATCH ── ./target/release/netwatch needs `ss` ↑↓ select ↵ launch [r]echeck [q]uit @@ -26,19 +28,22 @@ and it runs; quit it and you are back here. ## Nothing is described twice -The launcher holds no list of widgets, no descriptions, and no requirements -of its own. All three are read at startup from where they already live: - -- **Which widgets exist** — every `.py` in the directory that is not - `common.py`, `check.py`, or the launcher itself. The same rule `check.py` - uses, so the two can never disagree about what a widget is. -- **What each one does** — its own first docstring line, the one you get from - `python3 --help`. +The launcher writes none of this down twice. Every widget's description and +requirements are the ones that already exist elsewhere, compiled in at build +time rather than restated here: + +- **What each one does** — the same help text the binary itself answers + `--help` with, and its doc page from `docs/`, both taken with + `include_str!`. A doc page and the launcher's description of it cannot + drift apart, because they are the same bytes. +- **Which widgets exist** — a list in the launcher's own source, one entry + per binary. It is the one thing that is written down, because a binary + cannot enumerate its siblings the way a directory of scripts could. - **What each one needs** — the Needs column of the README's widget table. That last one is deliberate. It could have been restated here, and then there would be two descriptions of every requirement, drifting apart quietly. The -README's version cannot rot: `check.py` fails any widget missing a row in +README's version cannot rot: `check.rs` fails any widget missing a row in that table. The practical effect is that adding a widget adds it here. There is no list @@ -63,7 +68,7 @@ highlighted widget — its doc page's own opening example, marked as one. Every widget's doc page opens with a rendering of the widget it describes, maintained by whoever wrote it, so there is no second copy of anything here -either — the same arrangement as the descriptions. `matrix.py` has no doc +either — the same arrangement as the descriptions. `matrix` has no doc page on purpose and so has no picture; it gets the description alone. It says `example` on the frame because it is one. Static numbers in a live @@ -127,43 +132,44 @@ explanation with it — and in a tiled wall, or started from this menu, a line on stderr has nowhere to go. So it draws the reason and waits, answering `q` like everything else. -`link.py`, `netwatch.py`, `latency.py` and `herdr-panes.py` all do this, via -`cannot_start` in `common.py`. The first two used to exit; the second two used +`link`, `netwatch`, `latency` and `herdr-panes` all do this, via +`cannot_start` in `toys-core`. The first two used to exit; the second two used to run and quietly show nothing, which was worse. ## Launching## Launching `↵` hands the terminal over: cursor restored, raw mode off, the widget gets a normal terminal and this process waits. Quit the widget and the launcher -takes the terminal back and rechecks, so a token you set or a package you -installed while you were away is reflected without restarting. +takes the terminal back. + +Every widget is listed, whether or not this machine can run it. A widget that +is missing a tool or a token says so on its own screen, in its own words, +and `q` brings you back here - which is a better place to learn it than a +menu that has quietly hidden the row. Naming one skips the menu entirely, and anything after it is passed straight through: ```sh -python3 terminal-toys # the directory itself is runnable -./start.py # the menu, from inside it -./start.py netwatch # straight into one -./start.py netwatch -i 2 -n 5 # arguments go to the widget -./start.py link --help # including --help +./target/release/start # the menu +./target/release/start netwatch # straight into one +./target/release/start netwatch -i 2 -n 5 # arguments go to the widget +./target/release/start link --help # including --help ``` -The first form works because of `__main__.py`, which is Python's own -convention for an entry point: a directory containing one can be run by -name. It holds three lines and hands straight over to this script, so the -collection can be started without knowing which file inside it to name. +A widget is looked for beside the launcher's own binary, so a release +unpacked anywhere works without a path being configured. -That form uses `exec`, so the launcher replaces itself rather than sitting in -the middle of a pipeline it adds nothing to. +`start netwatch.py` is still accepted, and only for that: every widget here +answered to that name for years and the muscle memory outlives the files. +The suffix is stripped and the binary of the same stem runs. ## Keys | Key | Action | |---|---| | `↑` `↓` / `j` `k` | select a widget | -| `↵` | launch it, and come back here when it quits | -| `r` | recheck what is installed and configured | +| `↵` / `→` | launch it, and come back here when it quits | | `q` | quit | ## Cost diff --git a/docs/tailnet.md b/docs/tailnet.md index 472c0e5..4aa9aaa 100644 --- a/docs/tailnet.md +++ b/docs/tailnet.md @@ -1,4 +1,6 @@ -# `tailnet.py` +# `tailnet` + +[← all docs](README.md) Tailscale peers, and — the part plain `tailscale status` buries — *how* you are reaching each one. @@ -70,7 +72,7 @@ geolocation service), every address it has, its advertised routes, and whether it offers itself as an exit node. It also carries **live latency** for that peer — current, average, median, min, -max, jitter, loss and a sparkline, the same statistics `latency.py` reports, +max, jitter, loss and a sparkline, the same statistics `latency` reports, measured by ICMP over the tunnel. Only the selected peer is probed, so this costs one ping process no matter how large the tailnet, and history is kept per peer so returning to one still shows its earlier samples. @@ -93,11 +95,12 @@ advertises wins over a docker or virtual bridge: a NAS was otherwise reporting | Key | Action | |---|---| | `↑` `↓` | select a peer | -| `Enter` / `i` | machine info view | +| `→` / `Enter` | machine info view — `i` in the Python, which is being retired | +| `←` / `esc` | back out of the info or copy view | | `c` | copy addresses | | `g` | show/hide the live throughput graphs | | `o` | hide offline peers | -| `n` | poll interval — 1 / 2 / 5 / 10 / 30s | +| `i` | poll interval — 1 / 2 / 5 / 10 / 30s | | `r` | refresh now | | `q` | quit | @@ -120,5 +123,5 @@ peer dominates. "tailnet": { "refresh": 2, "history": 180 } ``` -Graph resolution follows the poll interval, so `n` doubles as a zoom control. +Graph resolution follows the poll interval, so `i` doubles as a zoom control. Needs the `tailscale` CLI; no root required. diff --git a/docs/usage.md b/docs/usage.md index be23df3..4293954 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,4 +1,6 @@ -# `usage.py` +# `usage` + +[← all docs](README.md) How much the coding agents on this machine have actually been used — one tab per agent, from each agent's own local state, plus a live quota reading for @@ -124,7 +126,7 @@ cache had settled — 31 sessions, a longest session of `4d 10h 52m`, a longest streak of 21 days, Aug 1 as the busiest day. The **tokens-per-day calendar** is laid out like the contribution calendar in -`github.py` — weekdays down the side, weeks across — so the two read the same +`github` — weekdays down the side, weeks across — so the two read the same way on one wall. The only difference is cell width. That pane spans a year, so its cells are one @@ -325,6 +327,100 @@ a Claude/GPT pair at 0%; those are real limits and are left in. It is present only while Antigravity is running, which the pane does not disguise: no process, no port, no section. +That was written here as *"the one agent with no account-wide quota endpoint +at all"*, and it was wrong. Google serves the same summary the language +server does: + +``` +POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary +``` + +Same bearer token the tier already uses, and the reason it looked absent is +the body: `loadCodeAssist` wants `{"metadata":{"pluginType":"GEMINI"}}`, and +sending that here is a 400 naming `metadata` as an unknown field — which +reads exactly like an endpoint that is not there. It takes `{}`. What comes +back is the same shape group for group and bucket for bucket, so it needs no +parser of its own. + +The local server is still preferred: it is the app's own answer and moves as +the app is used, where Google's is a record. The remote one is asked only +when there is no server to ask, held for an hour rather than two minutes, +and the heading says which was read: + +``` + ── QUOTA ── live · account-wide, from Google - the app is not running +``` + +So the quota survives the app being closed — for as long as the token lasts, +which is an hour, because the refresh token beside it is deliberately left +alone. Running `agy` does not extend that: it refreshes in memory and writes +nothing back, verified by backdating the `expiry` field, running `agy models` +against it, and finding the file byte-identical afterwards, mtime included. + +**Three sources, cheapest first**, each of them optional: + +| order | source | costs | works when | +|---|---|---|---| +| 1 | a language server already running — the app's, or an `agy` you started | a socket read | Antigravity is open | +| 2 | Google, `retrieveUserQuotaSummary` | one request | within an hour of the last run | +| 3 | `agy`, started by the widget (`antigravity_start`) | a process, a few seconds | whenever the CLI is signed in | + +Being last is not being disfavoured — it is being expensive. The third is the +only one that always works, and the only one that runs another program. + +**The pty is not decoration.** Started with its input on `/dev/null` the CLI +opens ports that answer nothing for as long as you wait; given a pseudo-terminal +it serves the quota within seconds. Both were measured before this was written. +The port that answers also belonged to a *child* of the process launched, so +the search covers descendants — scoped to the pid alone it finds two ports that +answer nothing and gives up. + +Nothing outlives the fetch: the child is killed by the pid `forkpty` returned +and then reaped, never matched by name, so a CLI you started yourself can +never be shut down by this — and it would have been found by source 1 long +before source 3 ran. Verified by counting `agy` processes before and after a +frame that used it: zero either side. + +**Every step that can fail says which step it was**, on the tab and on `[+]`, +because "no quota" covered four situations wanting different things from the +reader: + +``` + no quota either: Antigravity's token expired 51m ago - it refreshes them + itself, so open it or run `agy` once and sign in + no quota either: asking Google is off - set usage.antigravity_remote to true + no quota either: Antigravity has not signed in on this machine - run `agy` + once and sign in + no quota either: Google refused the Antigravity token: … +``` + +The first three are decided before anything leaves the machine, and before +the cache, so the sentence survives a held failure. A reason captured inside +the fetch closure does not: `cached` holds a refusal without re-running it, +so the row reverted to a generic "Google did not answer" about a token that +had expired an hour earlier and was never sent. + +When neither source answers, `[+]` names the agent with the reason rather +than sending the reader to open an app that would not have helped: + +``` + ANTIGRAVITY + no quota · neither the language server inside the app nor Google + answered. Open Antigravity, or sign in again if its token has lapsed. +``` + +That sentence used to be empty whenever the tier read perfectly well, which +is most of the time — so the summary named the agent as quiet and then said +nothing about why. + +**The roll-call is what is left over.** `No quota published by: …` once led +this block and named every quiet agent, with the explanations below it. That +reads backwards, and it said the same thing twice for any agent that had a +reason, since each reason already opens by saying there is no quota. Now +each agent that can explain itself leads with its own heading, and the +roll-call lists only those with nothing to say — vanishing entirely when +they all have. + The tier comes from the endpoint the CLI authenticates against: ``` @@ -824,6 +920,215 @@ seconds and these windows move over hours, so the earlier code was making six requests a minute — three calls, twice a minute — to be told the same thing. A failure is cached too, so a dead endpoint is retried occasionally instead of on every frame. +### Claude's fallback is our own snapshot, not Claude Code's + +Claude Code keeps a usage cache in `~/.claude.json` under +`cachedUsageUtilization`, and this widget read it whenever the live call +failed. With no age check at all — which turned out to matter, because +Claude Code's own reader has one: + +```js +let r = Date.now() - n.data.fetchedAtMs; +if (r < 0 || r > zNo) return null; // zNo = 3600000 +``` + +**It trusts that cache for one hour.** It writes it at most every five +minutes (`BNo = 300000`) and discards it past sixty. On the machine this was +found on, the entry was **9.6 days old** — Claude Code had been ignoring its +own cache for nine days while this widget drew it as a current percentage. +The block was byte-identical in a backup from the 16th, so it had not moved +since the 15th, while `~/.claude.json` itself is rewritten every few seconds. + +Not a removal and not a regression: the key is present in every installed +version, `2.1.243` references it more than the older ones, and the reader is +the same logic in `2.1.233` and `2.1.243` with the same constant. Why the +writer stopped firing on the 15th is unestablished — it is gated on an +account match and fed from API response data, and finding out would mean +instrumenting Claude Code. + +So there are two fallbacks now, newest wins: + +1. **Our own snapshot**, written to `$XDG_STATE_HOME/terminal-toys/claude-usage.json` + every time the live call answers — minutes old on a machine in use, and + not dependent on another program's cache still being maintained. +2. **Claude Code's**, but only inside the hour it trusts it for. + +CodexBar reached the same conclusion from the other end: its Claude sources +are the API and the CLI, never that file, and when they all fail it keeps +its own `history/claude.json` and shows the capture age rather than blanking +the bars. + +The snapshot carries the `accountUuid` Claude Code records, so switching +accounts does not show the old one's figures — the usage response itself +carries no account, so the marker is borrowed. A machine whose Claude Code +never wrote the key has no marker, and then the guard is simply not applied. + +### Grok is the fourth, and it is off by default + +Grok publishes no quota this widget can read without asking for it. The other +five agents each answer a host — `api.anthropic.com`, `chatgpt.com`, +`api.github.com`, `api2.cursor.sh`, `cloudcode-pa.googleapis.com`. Grok makes +no call at all: its figures come from `~/.grok/logs/unified.jsonl`, the log its +own CLI writes, so they move **only when you use Grok on this machine**. + +That failed quietly. A log left alone for nine days had the widget showing 23% +of a credit window that had closed on the 19th, while the account had spent 57% +of the window it was actually in. More than double, with nothing on screen to +say the figure was old. + +Three settings, all off or hourly by default: + +| key | default | what it does | +|---|---|---| +| `antigravity_start` | `true` | may the widget start the `agy` CLI to read the quota it serves, when nothing else has one. Started under a pty, killed by pid and reaped as soon as the reading is taken. Never touches a CLI you started | +| `antigravity_remote` | `true` | may Antigravity's quota be asked of Google (`cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary`) when no language server is running. Same host and same credential as the tier request. Off means no quota while the app is closed, and the tab says so | +| `grok_ping` | `false` | GET `cli-chat-proxy.grok.com/v1/billing` with the bearer token the Grok CLI leaves in `~/.grok/auth.json`, **and** run `grok agent stdio` to refresh that token — once after a session goes quiet, and once when the token is within ten minutes of lapsing | +| `grok_ping_minutes` | `5` | how often. The window moves over days, but the spend inside it moves while you work, so five minutes keeps the figure actionable; one small GET twelve times an hour | + +### Grok Bot (Cursor's weekly allowance) + +Cursor grants a weekly included allowance for its Grok Bot, separate from +the monthly plan. It is not part of `included` / `auto` / `api` and does not +share their reset, so it draws as a fourth bar carrying its own countdown, +and appears on `[+]` as its own lane. + +It needs no configuration: `DashboardService/GetSandUsageStatus` is reached +with the same bearer token, on the same RPC service, as the three plan lanes. + +That is worth stating, because the obvious route is a dead end. Cursor's own +website calls this as `POST cursor.com/api/dashboard/get-sand-usage-status`, +which is cookie-authenticated and answers the app's bearer token — and that +same token sent as a cookie — with a redirect to the login provider. Reading +only the website's route leads to putting a browser session cookie in +`config.json`; the Connect service exposes the same call, and the token +already on disk is enough. + +It is **best-effort by contract**: a missing, refused or unparseable answer +leaves the three plan bars exactly as they were. An extra lane must never be +able to take the tab down with it. + +The bar is drawn only when the account actually has an allowance — +Cursor states that as `hasNonZeroIncludedLimit`, and a 0% bar for an account +that was never granted one would invent a limit that does not exist. With a +cookie set and the lane still absent, the row says why instead. + +**One setting, not two.** The refresh was a second key for one release and +should not have been. The token expires — mine had lapsed 8.6 days before I +looked, on the same day the CLI last ran — so asking without refreshing works +for a while and then silently stops, which is the failure the refresh exists to +prevent. Nobody wants the first without the second, so turning on `grok_ping` +turns on both. + +**The refresh is keyed on the token, not on a session.** For a while it fired +only in the six hours after a session ended, and the token turned out to last +about six hours too — so anyone who had not run Grok since yesterday was in the +failure above with the fix switched on. It now also fires when the token is +about to lapse, whatever the last session was, subject to the same two guards: +`grok_ping` is on, and nothing is running that the CLI would start underneath. +It is attempted once per expiry value, so a login that has genuinely run out +costs one attempt rather than one every five minutes. + +**Off by default**, because it does two things a widget that reads has no +business doing unasked: it talks to a vendor, and it starts somebody else's +program. + +**Cached or live is a question about age, not about source.** A reading is +shown as current when it was taken within the last half hour, whatever +fetched it — the same rule and the same half hour as Claude's. Marking by +source instead put a star on a figure thirty seconds old while a live one +four minutes old carried none, and here it was worse: the live answer was +being discarded (below), so the row read `not live` whether the ping was +working or not, and turning it on changed nothing a reader could see. + +**A period without a percentage means nought used, not unknown.** The +credits endpoint omits `creditUsagePercent` when it is zero — proto3 leaves +out a scalar sitting at its default. That was briefly mistaken here for the +field having been withdrawn for accounts on unified billing, and the row +said there was no figure when the true figure was nought. + +The answer settles it against itself. Alongside the credit percentage it +returns `productUsage`, one entry per product: + +```json +"productUsage": [{"product": "GrokBuild", "usagePercent": 3.0}, + {"product": "GrokChat"}, + {"product": "GrokImagine"}] +``` + +The product with usage carries the key; the two at nought omit it, in the +same array of the same response. Watched over time as well: a weekly window +that had just reset returned no percentage at all, and began reporting one +once anything had been spent — same endpoint, same headers, same token. So +nought here is the reading rather than a guess, which is the only reason it +may be drawn. A response naming no period at all is still refused: nought +is only knowable against a window the server stated. + +That split is drawn under the window, because the bar above is one number +for three different things and which of them is spending is the part a +reader can act on. + +**The server's answer wins over the log.** Where the live answer names the +window but the log holds a percentage, the log's figure is used only if it +is about that same window — a percentage from a window that has closed is +not this one's. Before that rule the tab preferred an eleven-day-old log +line, about a window that had closed a week earlier, over the server's +current one, and rolled its reset forward with a `~`. + +The screen says which state it is in, in both places it appears: + +``` +── WEEKLY QUOTA ── resets in ~1.1 days + not live · ~/.grok/logs/unified.jsonl · window closed 5d 21h ago + Only your own Grok sessions update it. usage.grok_ping polls x.ai instead. +``` + +``` +── WEEKLY QUOTA ── resets in 1.1 days + live · polled x.ai just now, every 5m +``` + +``` +── WEEKLY QUOTA ── resets in 6d 19h + live · polled x.ai just now, every 5m + + 3% ██┃░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ credits used + window 26 Aug → 2 Sep + by product GrokBuild 3.0% · GrokChat 0% · GrokImagine 0% +``` + +When asking is on and the figure still is not the server's, the row says +which of the reasons applies rather than leaving `not live` to cover all of +them — only some are the reader's to fix: + +``` + not live · polled x.ai just now, every 5m · the token lapsed 3h ago - the Grok CLI refreshes it + not live · polled x.ai 4m ago, every 5m · x.ai did not answer + not live · polled x.ai just now, every 5m · x.ai sent no percentage for this period +``` + +The last of those is a 200 that names the billing period but sends a null +percentage. The log's reading is kept, because it is the only percentage +there is — but it belongs to an earlier window, so the row stays marked +`not live` and its reset keeps the `~` that says the date is rolled forward +rather than stated. + +The age quoted is the **reading's**, not the file's. The CLI touches that log +whenever it starts, so a file written minutes ago can still hold a credit +figure from a fortnight back, and "written 17m ago" beside a percentage reads +as a fresh percentage. + +When the recorded window has closed, its end date is rolled forward on the +window's own measured length until it covers now — the length is measured +rather than assumed to be seven days, because the server states the period type +and a fortnightly window should not be guessed weekly. That is a calculation +rather than a reading, so the countdown carries a `~`, and the **pace figure is +suppressed**: pace is usage against time elapsed, and how much of the current +window has been spent is exactly what nobody knows. + +CodexBar reaches the same endpoint and hits the same wall — it reads the cached +credential and does not refresh it either, so an expired token drops it back to +local session files, as this does to the log. + ## The pace mark on every quota bar ``` @@ -948,7 +1253,7 @@ refresh makes a tab a row longer. | Key | Action | |---|---| -| `←` `→` / `tab` | switch agent | +| `←` `→` / `tab` | switch agent. The new tab opens at its top: the tabs are different lengths and shapes, so a remembered offset opens the next one part-way down with its heading scrolled off | | `↑` `↓` | scroll the tab | | `pgup` `pgdn` | scroll a page | | `home` `end` | jump to the top or bottom | diff --git a/github.py b/github.py deleted file mode 100755 index 44a0167..0000000 --- a/github.py +++ /dev/null @@ -1,811 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""GitHub delivery metrics across every org and account you can see. - -Open pull requests, how many are actually merging, review backlog and issue -counts - for one org, several, or your personal account alongside them. - - python3 github.py [-n SECONDS] [account ...] - -Accounts are org logins, or @me for your own. With none given it uses -`github.accounts` from config, and failing that every org you belong to plus -your personal account. - -Open counts - PRs, issues, drafts, review backlog - are point-in-time totals of -whatever is open right now, at any age. Everything else - the merge rate, the -per-day charts and the per-account merged/rate columns - covers the merge -window, which is the N days ending today. - -Credentials: `github.token` in config.json, or $GITHUB_TOKEN. It must be a -*classic* token: a fine-grained one is limited to a single resource owner, so -it cannot span the orgs this board exists to compare. Two scopes - `repo` so search sees private -repositories, and `read:org` to enumerate your orgs. Missing either one does -not fail, it silently undercounts, so the granted scopes are checked and named. -The API is called directly, so the `gh` CLI is not required. - -Keys: up/down select an account, r refreshes now, w cycles the window -(7/14/30/60/90 days), q quits. -""" -import collections -import datetime -import math -import json -import os -import sys -import threading -import time -import urllib.error -import urllib.request - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (RST, Keyboard, bar, bg, big, braille_plot, - config_token_warning, cycle, dance, draw, mix, - heat, load_config, maybe_help, meter, pack_hints, pad, rgb, - seg, setup, size, skeleton, stacked_bar, title, vbars, - vbars_down) - -_CFG = load_config("github", { - "token": "", - "token_env": "GITHUB_TOKEN", - "accounts": [], # org logins and/or "@me"; empty = discover - "window_days": 7, # window the board opens on - "refresh": 120, # seconds between polls; GraphQL is 5000 points/hour -}) - -REFRESH = float(_CFG["refresh"]) -WINDOWS = (7, 14, 30, 60, 90) -CONTRIB_WEEKS = 52 # a full year, like the calendar on github.com -API = "https://api.github.com/graphql" - -OK = rgb(90, 240, 160) -WARN = rgb(255, 200, 90) -BAD = rgb(255, 100, 110) -DIM = rgb(127, 147, 172) -GRID = rgb(60, 78, 98) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) -PR = rgb(180, 160, 255) -# the chart fades from these into the real colours as figures land: pale -# enough to read as "not yet", tinted enough to still say which half is which -PR_RGB, OK_RGB = (180, 160, 255), (90, 240, 160) -GHOST = (96, 106, 124) -LOAD_PR, LOAD_OK = mix(GHOST, PR_RGB, 0.45), mix(GHOST, OK_RGB, 0.45) -SETTLE_FRAMES = 8 -SPARK = "▁▂▃▄▅▆▇█" - - -def token(): - """A GitHub token from config.json or the environment. - - Deliberately not shelled out to the `gh` CLI: this widget talks to the API - directly and should not require another program to be installed, logged in - and current just to read a number. - """ - if _CFG["token"]: - return _CFG["token"], "config" - tok = os.environ.get(_CFG["token_env"] or "GITHUB_TOKEN") - if tok: - return tok, "env" - return None, "missing" - - -_SCOPES = {"seen": False, "have": set()} - - -def graphql(query, tok): - body = json.dumps({"query": query}).encode() - req = urllib.request.Request(API, data=body, headers={ - "Authorization": "Bearer " + tok, - "Content-Type": "application/json", - "User-Agent": "terminal-toys", - }) - with urllib.request.urlopen(req, timeout=30) as r: - granted = r.headers.get("X-OAuth-Scopes") - if granted is not None: # absent on fine-grained tokens - _SCOPES["seen"] = True - _SCOPES["have"] = set(x.strip() for x in granted.split(",") if x.strip()) - return json.load(r) - - -def scope_warning(): - """Flag a token that will undercount rather than fail. - - A classic token without `repo` still searches happily - it just returns - public results only, so every figure on the board comes back smaller with - nothing to say it did. Without `read:org` the account list comes back - short the same way. Both are worse than an error, so name them. - """ - if not _SCOPES["seen"]: - return None - missing = [x for x in ("repo", "read:org") if x not in _SCOPES["have"]] - if not missing: - return None - why = ("private repos are not counted" if "repo" in missing - else "orgs cannot be discovered") - return "token lacks %s - %s" % (" and ".join(missing), why) - - -def discover_accounts(tok): - """Every org you belong to, plus your own account.""" - q = "{ viewer { login organizations(first:20) { nodes { login } } } }" - try: - d = graphql(q, tok)["data"]["viewer"] - except Exception: - return [] - return [o["login"] for o in d["organizations"]["nodes"]] + ["@me"] - - -def scope(acc, viewer): - """The search qualifier that limits results to a single account.""" - return ("user:%s" % viewer) if acc == "@me" else ("org:%s" % acc) - - -DAY_CHUNK = 20 # 2 searches a day; the alias ceiling sits between 60 and 90 -FRESH_DAYS = 2 # trailing days to always refetch: today is still running, - # and the search index lags a little behind a merge - - -def build_day_query(q, dates): - """Exact per-day PR counts for one account. - - The charts used to read PR nodes and bucket their timestamps, but a search - connection returns at most 100 nodes per page, so a busy fortnight lost - everything past the hundredth record - and the merged series sorted by - update time, so those hundred were not even the hundred most recently - merged. A count per day is exact at any volume, and aliased searches cost - one rate-limit point per request however many are packed into it. - """ - parts = ["{"] - for n, day in enumerate(dates): - parts.append( - '\n m%(n)d: search(query:"%(q)s is:pr is:merged merged:%(d)s",' - ' type:ISSUE) { issueCount }' - '\n c%(n)d: search(query:"%(q)s is:pr created:%(d)s",' - ' type:ISSUE) { issueCount }' % {"n": n, "q": q, "d": day}) - parts.append("\n}") - return "".join(parts) - - -def build_query(accounts, days, viewer): - """Metrics for a batch of accounts in one request. - - Seven aliased searches per account keeps each request within GitHub's - complexity limit - asking for seven accounts at once returned HTTP 502 - - while still being far fewer round trips than one query per metric. - """ - # N days *ending today*, so this spans exactly the dates the per-day - # charts plot - `days` rather than `days - 1` would cover one day more - # and quietly disagree with the chart drawn directly beneath it. - since = (datetime.date.today() - - datetime.timedelta(days=days - 1)).isoformat() - parts = ["{"] - for i, acc in enumerate(accounts): - q = scope(acc, viewer) - parts.append(''' - o%(i)d_open: search(query:"%(q)s is:pr is:open", type:ISSUE) { issueCount } - o%(i)d_draft: search(query:"%(q)s is:pr is:open draft:true", type:ISSUE) { issueCount } - o%(i)d_review: search(query:"%(q)s is:pr is:open review:required", type:ISSUE) { issueCount } - o%(i)d_merged: search(query:"%(q)s is:pr is:merged merged:>=%(s)s", type:ISSUE) { issueCount } - o%(i)d_dropped: search(query:"%(q)s is:pr is:unmerged is:closed closed:>=%(s)s", type:ISSUE) { issueCount } - o%(i)d_issues: search(query:"%(q)s is:issue is:open", type:ISSUE) { issueCount }''' - % {"i": i, "q": q, "s": since}) - parts.append("\n rateLimit { remaining limit }\n}") - return "".join(parts) - - -class Store(object): - def __init__(self, accounts, days): - self.lock = threading.Lock() - self.accounts = accounts - self.days = days - self.stats = [] - self.day_cache = {} # account -> {date: (merged, opened)} - self.bust_days = False # set by [r]: drop the day cache and refetch - self.calendar = None - self.rate = None - self.error = None - self.fetched = 0 - self.wake = threading.Event() - - def snapshot(self): - with self.lock: - return (list(self.stats), self.rate, self.error, self.fetched, - self.calendar) - - def run(self): - # A daemon thread that raises just stops, and a dead poller looks - # exactly like a source with no data - which is how deployments.py - # showed "0 deploys" for a day after an import went missing. - try: - self.poll() - except Exception as e: - with self.lock: - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:70]) - - def poll(self): - viewer = None - while True: - tok, source = token() - if not tok: - with self.lock: - self.error = ("no token: set github.token in config.json " - "or $%s (needs repo + read:org)" - % (_CFG["token_env"] or "GITHUB_TOKEN")) - self.wake.wait(REFRESH) - self.wake.clear() - continue - try: - if viewer is None: - viewer = graphql("{ viewer { login } }", - tok)["data"]["viewer"]["login"] - if not self.accounts: - self.accounts = discover_accounts(tok) or ["@me"] - try: - cal = graphql(contribution_query(CONTRIB_WEEKS), tok)["data"]["viewer"] - with self.lock: - self.calendar = cal["contributionsCollection"]["contributionCalendar"] - except Exception: - pass - with self.lock: - days_now, bust = self.days, self.bust_days - self.bust_days = False - # Start the pass from what is already on screen, keyed by - # account, so rows are replaced in place as each lands - # instead of the table emptying and refilling every pass. - by_acc = dict((x["key"], x) for x in self.stats) - today = datetime.date.today() - dates = [(today - datetime.timedelta(days=k)).isoformat() - for k in range(days_now - 1, -1, -1)] - keep_from = (today - datetime.timedelta( - days=max(WINDOWS) - 1)).isoformat() - failed, rate = [], None - - def publish(): - with self.lock: - self.stats = [by_acc[a] for a in self.accounts - if a in by_acc] - self.rate = rate - self.fetched = time.time() - - # Aggregates first, for every account, before any per-day work. - # One request each, so the headline is live in seconds; the day - # charts below can cost fifty requests on a cold 90d window and - # would otherwise hold the whole board grey for minutes. - for acc in self.accounts: - try: - data = graphql(build_query([acc], days_now, viewer), tok) - if data.get("errors"): - raise ValueError(data["errors"][0].get("message", "")[:50]) - except Exception as e: - failed.append("%s (%s)" % (acc, type(e).__name__)) - continue - d = data["data"] - rate = d.get("rateLimit") or rate - i = 0 - g = lambda k: (d.get("o%d_%s" % (i, k)) or {}).get("issueCount", 0) - merged, dropped = g("merged"), g("dropped") - prev = by_acc.get(acc) or {} - by_acc[acc] = { - "key": acc, - "window": days_now, # which window these figures cover - "account": viewer if acc == "@me" else acc, - "is_me": acc == "@me", - "open": g("open"), "draft": g("draft"), - "review": g("review"), "issues": g("issues"), - "merged": merged, "dropped": dropped, - "rate": (100.0 * merged / (merged + dropped) - if merged + dropped else None), - # carried over until this account's day counts land, and - # tagged with the window they actually cover so a - # half-updated board cannot sum two windows together - "hist": prev.get("hist") or collections.Counter(), - "opened_hist": prev.get("opened_hist") or collections.Counter(), - "hist_window": prev.get("hist_window"), - } - publish() - - # Then the per-day counts. A past day cannot change - a PR - # merged on the 3rd stays merged on the 3rd - so only days never - # seen before, plus the trailing few (still running, and the - # search index lags), cost a request. Widening the window - # therefore buys only the days it adds; narrowing is free. - for acc in self.accounts: - if acc not in by_acc: - continue - cache = self.day_cache.setdefault(acc, {}) - if bust: - cache.clear() - want = [x for x in dates - if x not in cache or x in dates[-FRESH_DAYS:]] - for c in range(0, len(want), DAY_CHUNK): - chunk = want[c:c + DAY_CHUNK] - try: - dd = graphql(build_day_query(scope(acc, viewer), - chunk), tok)["data"] - except Exception: - continue - for n, day in enumerate(chunk): - cache[day] = ((dd.get("m%d" % n) or {}).get("issueCount", 0), - (dd.get("c%d" % n) or {}).get("issueCount", 0)) - for old_day in [x for x in cache if x < keep_from]: - del cache[old_day] # older than any window - if not all(x in cache for x in dates): - continue # a chunk failed; leave it - by_acc[acc]["hist"] = collections.Counter( - dict((x, cache[x][0]) for x in dates)) - by_acc[acc]["opened_hist"] = collections.Counter( - dict((x, cache[x][1]) for x in dates)) - by_acc[acc]["hist_window"] = days_now - publish() - with self.lock: - # with nothing else to report, surface a token sitting in a - # file other users on the box can read - self.error = (("could not read: " + ", ".join(failed)) - if failed else - (scope_warning() or - (config_token_warning() - if source == "config" else None))) - except urllib.error.HTTPError as e: - with self.lock: - self.error = "HTTP %s from GitHub%s" % ( - e.code, " (token lacks scope?)" if e.code == 403 else "") - except Exception as e: - with self.lock: - self.error = "%s: %s" % (type(e).__name__, str(e)[:60]) - self.wake.wait(REFRESH) - self.wake.clear() - - -def contribution_query(weeks): - """GitHub's own contribution calendar - the green squares. - - contributionsCollection is per-viewer rather than per-org, so this is your - activity across everything, which is what the calendar means on github.com. - """ - since = (datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(weeks=weeks)).strftime("%Y-%m-%dT%H:%M:%SZ") - return """{ viewer { contributionsCollection(from:"%s") { - contributionCalendar { totalContributions - weeks { contributionDays { date contributionCount weekday } } } } } }""" % since - - -WEEKDAYS = ("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") - - -def calendar_stats(weeks_data): - """Streaks and totals behind the contribution calendar. - - A streak is consecutive days carrying at least one contribution, counted - the way github.com does it: a day that has scored nothing *so far* does - not break the current streak, because it is not over yet. - """ - days = sorted((d["date"], d["contributionCount"], d.get("weekday", 0)) - for wk in weeks_data for d in wk["contributionDays"]) - if not days: - return None - today = datetime.date.today().isoformat() - - longest = run = 0 - for _d, count, _wd in days: - run = run + 1 if count else 0 - longest = max(longest, run) - - done = [x for x in days if x[0] <= today] - if done and not done[-1][1]: - done = done[:-1] # today is still in progress - current = 0 - for _d, count, _wd in reversed(done): - if not count: - break - current += 1 - - per_weekday = collections.Counter() - for _d, count, wd in days: - per_weekday[wd] += count - top_wd = per_weekday.most_common(1)[0][0] if per_weekday else 0 - return { - "today": dict((d, c) for d, c, _ in days).get(today, 0), - "current": current, - "longest": longest, - "active": sum(1 for _d, c, _wd in days if c), - "span": len(days), - "busiest": max(days, key=lambda x: x[1]), - "weekday": (WEEKDAYS[top_wd], per_weekday[top_wd]), - } - - -def heatmap(weeks_data, w): - """The calendar as seven rows of one cell per week.""" - levels = " ░▒▓█" - counts = [d["contributionCount"] for wk in weeks_data - for d in wk["contributionDays"]] - peak = max(counts) if counts else 0 - cols = max(4, min(len(weeks_data), w - 8)) - weeks_data = weeks_data[-cols:] - grid = [[" "] * len(weeks_data) for _ in range(7)] - for x, wk in enumerate(weeks_data): - for d in wk["contributionDays"]: - n = d["contributionCount"] - lvl = 0 if not n else min(4, 1 + int(n / (peak or 1) * 3.99)) - grid[d["weekday"]][x] = levels[lvl] - return grid, peak, sum(counts) - - -def ago(t): - if not t: - return "--" - s = time.time() - t - return "%ds" % s if s < 90 else ("%dm" % (s / 60) if s < 5400 else "%dh" % (s / 3600)) - - -def main(): - maybe_help(__doc__) - global REFRESH - args = sys.argv[1:] - while args and args[0] in ("-n", "--refresh"): - REFRESH = max(30.0, float(args[1])) - args = args[2:] - accounts = args or list(_CFG["accounts"]) - days = int(_CFG["window_days"]) - - setup() - keyboard = Keyboard() - store = Store(accounts, days) - th = threading.Thread(target=store.run) - th.daemon = True - th.start() - - selected = 0 - tick = 0 - settle_t, settle_from = 0, None - while True: - tick += 1 - for key in keyboard.poll(): - if key in ("q", "Q"): - keyboard.restore() - raise SystemExit(0) - if key == "r": - with store.lock: # manual refresh re-reads even past days - store.bust_days = True - store.wake.set() - elif key == "w": - with store.lock: - store.days = cycle(WINDOWS, store.days) - store.wake.set() - elif key == "up": - selected = max(0, selected - 1) - elif key == "down": - selected += 1 - - w, h = size() - stats, rate, err, fetched, calendar = store.snapshot() - # Busiest first: open PRs decide it, and merged-in-window breaks ties - # so an idle backlog ranks below an account of the same size that is - # actually moving. Name last, to keep the order steady frame to frame. - stats.sort(key=lambda x: (-x["open"], -x["merged"], x["account"].lower())) - # Windowed figures are stale until every account has reported for the - # window now selected; rows carry the window they were fetched for. - # The charts are tracked apart from the headline because their data - # costs far more requests and so lands well after it. - stale = not stats or any(x.get("window") != store.days for x in stats) - chart_stale = not stats or any(x.get("hist_window") != store.days - for x in stats) - selected = max(0, min(selected, len(stats) - 1)) if stats else 0 - - rows = [title("github ops", w, PR)] - head = [(DIM, " %d account%s" % (len(stats), "" if len(stats) == 1 else "s")), - (DIM, " updated %s ago" % ago(fetched))] - if rate: - left = rate.get("remaining", 0) - head.append((OK if left > 1000 else WARN, " %d/%d api" % (left, rate.get("limit", 0)))) - rows.append(seg(head, w - 1)) - if err: - rows.append(seg([(BAD, " ! " + err)], w - 1)) - if not stats: - rows.append(seg([(DIM, " collecting…")], w - 1)) - draw(rows, w, h) - time.sleep(0.4) - continue - - # totals across every account - tot = {k: sum(s[k] for s in stats) - for k in ("open", "draft", "review", "issues", "merged", "dropped")} - rate_pct = (100.0 * tot["merged"] / (tot["merged"] + tot["dropped"]) - if tot["merged"] + tot["dropped"] else None) - # What is outstanding right now leads the board: it is the question - # asked most often, and it is the only section that is not windowed. - if tot["open"]: - ready = max(0, tot["open"] - tot["draft"] - tot["review"]) - legend = [x for x in (("awaiting review", tot["review"], WARN), - ("ready to merge", ready, OK), - ("draft", tot["draft"], DIM)) if x[1]] - rows.append(seg([(LBL, " ── OPEN PR STATE ── "), - (PR, "%d" % tot["open"]), (DIM, " PRs · "), - (WARN, "%d" % tot["issues"]), - (DIM, " issues open (any age)")], w - 1)) - parts = [(n / float(tot["open"]), c) for _, n, c in legend] - rows.append(seg([(RST, " ")] + stacked_bar(parts, max(10, w - 3)), - w - 1)) - key = [(RST, " ")] - for label, count, colour in legend: - key += [(colour, "▇ "), (TXT, label), - (DIM, " %d (%.0f%%) " % (count, 100.0 * count / tot["open"]))] - rows.append(seg(key, w - 1)) - - rows.append("") - pct_txt = ("%.0f%%" % rate_pct) if rate_pct is not None else "--" - rcol = heat((rate_pct or 0) / 100.0) if rate_pct is not None else DIM - rows.append(seg([(LBL, " ── MERGE RATE ── "), - (DIM, "last %d days" % store.days)], w - 1)) - bar_w = max(10, w - 34) - if stale: - rows.append(seg([(DIM, " %-5s" % "···")] + skeleton(bar_w, tick) + - [(DIM, " loading %dd…" % store.days)], w - 1)) - else: - rows.append(seg([(rcol, " %-5s" % pct_txt), - (rcol, meter((rate_pct or 0) / 100.0, bar_w)), - (OK, " %d merged" % tot["merged"]), - (DIM, " / "), (BAD, "%d dropped" % tot["dropped"])], - w - 1)) - merged_all, opened_all = collections.Counter(), collections.Counter() - for st in stats: - if st.get("hist_window") != store.days: - continue # covers a different window; adding it lies - merged_all.update(st["hist"]) - opened_all.update(st.get("opened_hist") or {}) - hist_days = store.days - today = datetime.date.today() - - days = [(today - datetime.timedelta(days=n)).isoformat() - for n in range(hist_days - 1, -1, -1)] - - # The chart always fills the pane. Where there is room to spare a day - # takes several columns (with a gap between bars once they are wide - # enough to need one); where there is not, the oldest days are cropped - # rather than the whole chart being squeezed into a corner. - avail = max(10, w - 3) - if len(days) > avail: - days = days[-avail:] - slot = max(1, avail // len(days)) - gap = 1 if slot >= 3 else 0 - barw = slot - gap - - def spread(per_day): - """One value per day, widened to the bar it will be drawn as. - - Everything the chart draws goes through this, the loading - animation included, so the placeholder has exactly the bars the - real chart will have - same count, same width, same gaps. - """ - cols = [] - for n, v in enumerate(per_day): - cols.extend([v] * barw) - if gap and n < len(per_day) - 1: - cols.extend([0] * gap) - return cols - - # One chart, two directions: PRs opened grow up, PRs merged grow down - # from a shared baseline. Read together they answer whether the queue - # is filling faster than it drains - which two separate charts made - # you compare by eye across a heading. - opened_day = [opened_all.get(d, 0) for d in days] - merged_day = [merged_all.get(d, 0) for d in days] - up, down = spread(opened_day), spread(merged_day) - chart_cols = len(up) - # One scale both ways, or the comparison lies. - span_hi = max(up + down) or 1 - # A narrow pane cannot draw 90 columns, so the chart shows the most - # recent days that fit and says so - the totals are of what is drawn, - # not of the window, and would otherwise contradict the merge rate. - span = ("%dd of %dd" % (len(days), hist_days) - if len(days) < hist_days else "%dd" % len(days)) - rows.append("") - if chart_stale: - # totals across a half-updated board would sum two windows - rows.append(seg([(LBL, " ── PR FLOW ── "), - (DIM, "counting %dd…" % hist_days)], w - 1)) - else: - # totals come from the days themselves: a day spans several - # columns now, so summing the columns would multiply by bar width - rows.append(seg([(LBL, " ── PR FLOW ── "), (DIM, "%s · " % span), - (PR, "▲ %d opened" % sum(opened_day)), - (DIM, " · "), - (OK, "▼ %d merged" % sum(merged_day)), - (DIM, " peak %d/day" % span_hi)], w - 1)) - # While the figures are still arriving the bars bounce like a level - # meter in pale versions of their own colours, then settle onto the - # real values rather than cutting to them. Three rows each side, always - # - trimming the unused half would make the chart change height at the - # end of the animation, which is exactly when it should be still. - if chart_stale: - # dance per day, then widen: bouncing each column on its own would - # show a twelve-column day as twelve separate thin bars - hu = spread(dance(len(days), tick)) - hd = spread(dance(len(days), tick, phase=2.1)) - cu, cd = LOAD_PR, LOAD_OK - settle_from, settle_t = (hu, hd), 0 - else: - real_u = [v / float(span_hi) for v in up] - real_d = [v / float(span_hi) for v in down] - if (settle_from and settle_t < SETTLE_FRAMES - and len(settle_from[0]) == chart_cols): - settle_t += 1 - q = settle_t / float(SETTLE_FRAMES) - q = q * q * (3 - 2 * q) # ease in and out of the move - hu = [a + (b - a) * q for a, b in zip(settle_from[0], real_u)] - hd = [a + (b - a) * q for a, b in zip(settle_from[1], real_d)] - cu, cd = mix(GHOST, PR_RGB, 0.45 + 0.55 * q), mix( - GHOST, OK_RGB, 0.45 + 0.55 * q) - else: - hu, hd, cu, cd = real_u, real_d, PR, OK - for line in vbars([(v, cu) for v in hu], 3, hi=1.0): - rows.append(seg([(RST, " ")] + line, w - 1)) - # an explicit baseline: without it the two series abut and the eye - # cannot tell which row the bars grow from - rows.append(seg([(RST, " "), (GRID, "─" * chart_cols)], w - 1)) - for line in vbars_down([(v, cd) for v in hd], 3, hi=1.0): - rows.append(seg([(RST, " ")] + line, w - 1)) - left = "%dd ago" % len(days) - rows.append(seg([(DIM, " " + left), - (DIM, " " * max(1, chart_cols - len(left) - 5)), - (DIM, "today")], w - 1)) - - rows.append("") - - # The account table earns the remaining height: it scrolls within - # whatever is left, while the calendar below it spends eight rows on - # decoration. The calendar keeps its place only where the pane is tall - # enough for both. - if calendar and h > 38: - grid, peak, total = heatmap(calendar["weeks"], w) - total_c = calendar.get("totalContributions", total) - rows.append(seg([(LBL, " ── CONTRIBUTIONS ── "), - (DIM, "%d in %d weeks, peak %d/day" - % (total_c, CONTRIB_WEEKS, peak))], - w - 1)) - for r, line in enumerate(grid): - # Rows are GitHub's own weekday index, where 0 is Sunday - - # grid[d["weekday"]] above - so the labels come off the same - # constant rather than a hand-written tuple. Written out - # Monday-first, they sat one row early and put today under - # yesterday's name. - label = WEEKDAYS[r] if r in (1, 3, 5) else "" - rows.append(seg([(DIM, " %-4s" % label), (OK, "".join(line))], - w - 1)) - cs = calendar_stats(calendar["weeks"]) - if cs: - bd, bc, _bw = cs["busiest"] - cells = [ - ("current streak", "%d days" % cs["current"], - OK if cs["current"] else DIM), - ("longest streak", "%d days" % cs["longest"], TXT), - ("today", "%d" % cs["today"], - OK if cs["today"] else DIM), - ("active days", "%d of %d (%.0f%%)" - % (cs["active"], cs["span"], - 100.0 * cs["active"] / cs["span"]), TXT), - ("busiest", "%s (%d)" % (bd, bc), TXT), - ("most on", "%s (%d)" % cs["weekday"], TXT), - ] - # as many columns as the width honestly allows, never fewer - # than one - the labels are what make these readable - ncols = 3 if w >= 86 else (2 if w >= 58 else 1) - cw = (w - 2) // ncols - for n in range(0, len(cells), ncols): - line = [(RST, " ")] - for label, value, colour in cells[n:n + ncols]: - used = len(label) + 1 + len(value) - line += [(DIM, label + " "), (colour, value), - (RST, " " * max(2, cw - used))] - rows.append(seg(line, w - 1)) - rows.append("") - - # Scroll rather than truncate: the selection has to stay on screen, or - # the arrows move something invisible. Keep it centred where there is - # room either side, and pinned at the ends of the list. Two header - # lines and the footer come out of the height before the rows do. - room = max(1, h - 5 - len(rows)) - first = 0 - if len(stats) > room: - first = min(max(0, selected - room // 2), len(stats) - room) - counter = (" %d-%d of %d" % (first + 1, min(first + room, len(stats)), - len(stats)) - if len(stats) > room else "") - rows.append(seg([(LBL, " ── BY ACCOUNT ──"), (DIM, counter)], w - 1)) - wide = w >= 62 - # No separators between these fields: the row emits %5d/%6s - # back-to-back, so a space here drifts the header one column per - # field - four by the time it reaches RATE. - # MRG takes seven: "MRG60D" is six characters and would sit flush - # against REVW in every window but the seven-day one. - head = " %-20s%5s%5s%7s%6s" % ("ACCOUNT", "OPEN", "REVW", - "MRG%dD" % store.days, "RATE") - bar_cols = max(4, w - 64) - spark_days = [(today - datetime.timedelta(days=n)).isoformat() - for n in range(min(store.days, bar_cols) - 1, -1, -1)] - if wide: - head += "%7s" % "ISSUES" - # Each row is scaled to its own busiest day, so a full block is - # 31 merged on one account and 16 on another. Say what the reader - # may do with it - read the shape - rather than naming the - # mechanism, which is what "OWN PEAK" did until someone asked. - # Pick the longest label that fits rather than clipping one: - # "MERGED/DAY" cut to "MERGED" would be a truncated hint, and the - # scale caveat is worth keeping down to the narrowest width it - # will fit in. - for label in ("MERGED/DAY · SHAPE ONLY, NOT TO SCALE", - "MERGED/DAY · SHAPE ONLY", - "MERGED/DAY (shape)", - "MERGED/DAY", ""): - if len(label) <= bar_cols: - break - head += (" " + label) if label else "" - rows.append(DIM + pad(head, w - 1)) - for i, s in list(enumerate(stats))[first:first + room]: - here = i == selected - tint = bg(38, 56, 76) if here else "" - r = s["rate"] - # this row's own staleness: accounts land one at a time, so an - # account already refetched for the new window shows real numbers - # while the ones behind it still shimmer - old = s.get("window") != store.days - line = [(tint + (ACCENT if here else TXT), - ("▸" if here else " ") + pad(s["account"] + (" (you)" if s["is_me"] else ""), 20)), - (tint + PR, "%5d" % s["open"]), - (tint + (WARN if s["review"] else DIM), "%5d" % s["review"]), - (tint + (DIM if old else OK), - "%7s" % ("···" if old else s["merged"])), - (tint + (DIM if old else - (heat(r / 100.0) if r is not None else DIM)), - "%6s" % ("···" if old else - ("%.0f%%" % r if r is not None else "--")))] - if wide: - line.append((tint + DIM, "%7d" % s["issues"])) - # Each account's own merged-per-day. The columns carry totals - # but no shape, and a fortnight of nothing ending in a spike - # reads very differently from a steady trickle. Scaled to this - # account's own peak: the absolute is in MRG two columns left, - # so the useful thing here is the shape. - hist = s.get("hist") or {} - if s.get("hist_window") != store.days: - line.append((tint + GRID, " " + "·" * len(spark_days))) - else: - top = max(hist.values()) if hist else 0 - marks = "" - for d in spark_days: - v = hist.get(d, 0) - marks += (SPARK[min(7, int(v / float(top) * 7.99))] - if v and top else " ") - line.append((tint + OK, " " + marks)) - if here: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - - hints = [[(ACCENT, "↑↓"), (DIM, " account")], [(DIM, "[w]indow")], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]] - footer = [" " + line for line in pack_hints(hints, w - 2)] - rows = rows[:h - len(footer)] - while len(rows) < h - len(footer): - rows.append("") - rows.extend(footer) - draw(rows, w, h) - time.sleep(0.3) - - -main() diff --git a/herdr-panes.py b/herdr-panes.py deleted file mode 100755 index e4a255f..0000000 --- a/herdr-panes.py +++ /dev/null @@ -1,517 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Everything running in Herdr, across every workspace. - -Two sections. AGENTS lists recognised coding agents with the lifecycle state -Herdr reports, ordered so the ones wanting a human come first. PROCESSES lists -every other pane that is actually running something — dev servers, monitors, -builds — with what it is running and what it costs. IDLE lists the panes -sitting at a shell prompt, by directory, so they can still be jumped to; -toggle that section with o. - -Enter jumps to whatever is selected: the agent's pane, or the tab holding that -process. - -A Herdr client, not a general agent monitor: the inventory and the lifecycle -states come from `herdr agent list`, the workspace labels from -`herdr workspace list`, and the pid behind each pane from -`herdr pane process-info`. Any agent kind Herdr recognises appears here with -no change to this file. - -On a terminal server hosting many workspaces, agents finish or get stuck in -places you are not currently looking. This lists every agent with the state -Herdr reports, ordered so the ones wanting your attention are at the top: - - blocked waiting on an approval or a question, right now - done finished background work you have not looked at yet - working busy - idle ready for input - unknown an agent is present but Herdr cannot classify it - -Each row also carries the workspace, how long the agent has held its current -state, and the real CPU and memory of its process. A duration is prefixed with -≥ when the state was already in place before this tool started, since then it -is only a lower bound. - - python3 herdr-panes.py [-n SECONDS] - -Keys: up/down select, Enter (or f) focuses that agent's pane so you jump -straight to whatever needs you, w toggles workspace labels vs pane ids, -r refreshes now, q quits. -Requires HERDR_ENV; it shells out to the `herdr` CLI. -""" -import collections -import json -import os -import subprocess -import sys -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (cannot_start, missing, RST, Keyboard, bg, draw, heat, load_config, maybe_help, - pack_hints, pad, rgb, seg, setup, size, title) - -_CFG = load_config("herdr_panes", {"refresh": 4.0}) -REFRESH = float(_CFG["refresh"]) # seconds between herdr polls (-n) - -BLOCKED = rgb(255, 105, 115) -DONE = rgb(90, 240, 160) -WORKING = rgb(255, 200, 90) -IDLE = rgb(128, 148, 172) -UNKNOWN = rgb(150, 150, 165) -DIM = rgb(127, 147, 172) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) -PROC = rgb(170, 190, 215) -IDLE_C = rgb(122, 138, 160) - -# ordering: what needs a human first -RANK = {"blocked": 0, "done": 1, "working": 2, "idle": 3, "unknown": 4} -COLOR = {"blocked": BLOCKED, "done": DONE, "working": WORKING, - "idle": IDLE, "unknown": UNKNOWN} -MARK = {"blocked": "⚠", "done": "✓", "working": "◐", "idle": "·", "unknown": "?"} -SPINNER = "◐◓◑◒" - - -def herdr_action(*args): - """Run a herdr command for its effect; True when it succeeded.""" - try: - out = subprocess.run(("herdr",) + args, capture_output=True, text=True, - timeout=15) - return out.returncode == 0 - except Exception: - return False - - -def herdr(*args): - try: - out = subprocess.run(("herdr",) + args, capture_output=True, text=True, - timeout=15) - return json.loads(out.stdout)["result"] - except Exception: - return None - - -def tail_path(path, n): - """Keep the end of a path, marking the cut so it does not read as a name.""" - if len(path) <= n: - return path - return "…" + path[-(n - 1):] - - -def command_label(proc): - """Readable name for what a pane is running. - - "python3" or "node" says nothing useful, so prefer the script they were - handed; otherwise fall back to the executable's own name. - """ - argv = proc.get("argv") or [] - if not argv: - return proc.get("name") or "?" - head = os.path.basename(argv[0]) - if head.split(".")[0] in ("python", "python3", "node", "ruby", "perl", "bun", - "deno", "sh", "bash", "zsh") and len(argv) > 1: - for token in argv[1:]: - if not token.startswith("-"): - return os.path.basename(token) - return head - - -def proc_stats(pid): - """(cpu_ticks, rss_bytes) for a pid, or None.""" - try: - with open("/proc/%d/stat" % pid) as f: - rest = f.read().rpartition(")")[2].split() - return int(rest[11]) + int(rest[12]), int(rest[21]) * 4096 - except (OSError, IndexError, ValueError): - return None - - -class Store(object): - def __init__(self): - self.lock = threading.Lock() - self.agents = [] - self.panels = [] - self.labels = {} - self.error = None - self.wake = threading.Event() - self.since = {} # pane_id -> (state, first_seen, exact) - self.first_poll = True - self.cpu = {} # pid -> (ticks, wall) for delta CPU - - def snapshot(self): - with self.lock: - return (list(self.agents), list(self.panels), dict(self.labels), - self.error) - - def _panels(self, now, hz): - """Non-agent panes that are actually running something. - - A pane sitting at its shell prompt has nothing to report, so those are - skipped: when a command runs, the foreground pid differs from the - pane's own shell pid. - """ - listing = herdr("pane", "list") or {} - out = [] - for pane in (listing.get("panes") or []): - if pane.get("agent"): - continue - pid_info = herdr("pane", "process-info", "--pane", pane["pane_id"]) or {} - info = pid_info.get("process_info") or {} - fg = info.get("foreground_processes") or [] - busy = bool(fg) and fg[0].get("pid") != info.get("shell_pid") - proc = fg[0] if fg else {} - pid = proc.get("pid") if busy else None - entry = {"pane_id": pane.get("pane_id"), "tab_id": pane.get("tab_id"), - "workspace_id": pane.get("workspace_id"), - "command": command_label(proc) if busy else "", - "title": pane.get("terminal_title_stripped") or "", - "idle": not busy, "pid": pid, - "cwd": (proc.get("cwd") if busy else None) - or pane.get("cwd") or "", - "cpu": None, "rss": None} - st = proc_stats(pid) if pid else None - if st: - ticks, rss = st - entry["rss"] = rss - prev = self.cpu.get(pid) - if prev and now - prev[1] > 0: - entry["cpu"] = ((ticks - prev[0]) / float(hz) - / (now - prev[1]) * 100.0) - self.cpu[pid] = (ticks, now) - out.append(entry) - out.sort(key=lambda e: (e["idle"], -(e["cpu"] or 0))) - return out - - def run(self): - # A daemon thread that raises just stops, and a dead poller looks - # exactly like a source with no data - which is how deployments.py - # showed "0 deploys" for a day after an import went missing. - try: - self.poll() - except Exception as e: - with self.lock: - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:70]) - - def poll(self): - hz = os.sysconf("SC_CLK_TCK") - while True: - res = herdr("workspace", "list") - labels = {} - if res: - for w in (res.get("workspaces") or []): - labels[w.get("workspace_id")] = w.get("label") or "" - res = herdr("agent", "list") - if res is None: - with self.lock: - self.error = "herdr CLI unavailable (is HERDR_ENV set?)" - self.wake.wait(REFRESH) - self.wake.clear() - continue - - now = time.time() - agents = [] - for a in (res.get("agents") or []): - pane = a.get("pane_id") - state = a.get("agent_status") or "unknown" - was = self.since.get(pane) - if not was or was[0] != state: - # a state already in place when we started is only a lower - # bound - we did not see it begin - self.since[pane] = (state, now, not self.first_poll) - a = dict(a) - a["since"] = now - self.since[pane][1] - a["exact"] = self.since[pane][2] - - info = herdr("pane", "process-info", "--pane", pane) or {} - fg = (info.get("process_info") or {}).get("foreground_processes") or [] - a["pid"] = fg[0]["pid"] if fg else None - a["cpu"] = None - a["rss"] = None - if a["pid"]: - st = proc_stats(a["pid"]) - if st: - ticks, rss = st - a["rss"] = rss - prev = self.cpu.get(a["pid"]) - if prev: - dt = now - prev[1] - if dt > 0: - a["cpu"] = (ticks - prev[0]) / float(hz) / dt * 100.0 - self.cpu[a["pid"]] = (ticks, now) - agents.append(a) - - agents.sort(key=lambda x: (RANK.get(x.get("agent_status"), 9), - -x.get("since", 0))) - panels = self._panels(now, hz) - with self.lock: - self.agents, self.panels = agents, panels - self.labels, self.error = labels, None - self.first_poll = False - self.wake.wait(REFRESH) - self.wake.clear() - - -def ago(s): - s = int(max(0, s)) - if s < 60: - return "%ds" % s - if s < 3600: - return "%dm" % (s / 60) - if s < 86400: - return "%dh%02dm" % (s / 3600, s % 3600 / 60) - return "%dd" % (s / 86400) - - -def mem(b): - if b is None: - return " -- " - for unit in ("B", "K", "M", "G"): - if b < 1024: - return "%4.0f%s" % (b, unit) - b /= 1024.0 - return "%4.1fT" % b - - -def main(): - maybe_help(__doc__) - global REFRESH - args = sys.argv[1:] - if args and args[0] in ("-n", "--refresh"): - REFRESH = max(1.0, float(args[1])) - args = args[2:] - - absent = missing("herdr") - if absent: - cannot_start( - "herdr panes", absent, - ["This reads a running Herdr session through its own CLI: the", - "workspaces, the panes in them, and which agent is in which.", - "There is no other source for any of it.", - "", - "If Herdr is installed but not on PATH, this widget will find", - "it as soon as the shell can."], - "see https://herdr.dev") - - setup() - keyboard = Keyboard() - store = Store() - th = threading.Thread(target=store.run) - th.daemon = True - th.start() - - show_labels = True - show_idle = True - selected = 0 - scroll = 0 - note = "" - note_until = 0 - visible = 1 - tick = 0 - while True: - tick += 1 - for key in keyboard.poll(): - if key in ("q", "Q"): - keyboard.restore() - raise SystemExit(0) - if key == "r": - store.wake.set() - elif key == "w": - show_labels = not show_labels - elif key == "o": - show_idle = not show_idle - selected = 0 - elif key == "up": - selected = max(0, selected - 1) - elif key == "down": - selected += 1 - elif key == "home": - selected = 0 - elif key == "end": - selected = max(0, len(agents_now) - 1) - elif key in ("enter", "f"): - if agents_now: - kind, target = agents_now[min(selected, len(agents_now) - 1)] - pane = target.get("pane_id") - if kind == "agent": - ok = herdr_action("agent", "focus", pane) - what = target.get("agent") - else: - # non-agent panes have no focus-by-id; focusing the tab - # brings the pane into view, since a tab tiles its panes - ok = herdr_action("tab", "focus", target.get("tab_id")) - what = target.get("command") - note = ("→ focused %s in %s" % (what, pane) - if ok else "! could not focus %s" % pane) - note_until = time.time() + 3 - - w, h = size() - agents, panels, labels, err = store.snapshot() - entries = ([("agent", a) for a in agents] + - [("proc", p) for p in panels - if show_idle or not p["idle"]]) - agents_now = entries - selected = max(0, min(selected, len(entries) - 1)) if entries else 0 - if note and time.time() > note_until: - note = "" - counts = collections.Counter(a.get("agent_status") for a in agents) - - rows = [title("herdr panes", w, ACCENT)] - summary = [(DIM, " %d agent%s" % (len(agents), "" if len(agents) == 1 else "s")), - (DIM, " · %d workspace%s" % ( - len({a.get("workspace_id") for a in agents}), - "" if len({a.get("workspace_id") for a in agents}) == 1 else "s"))] - for state in ("blocked", "done", "working", "idle"): - if counts.get(state): - summary.append((COLOR[state], " %d %s" % (counts[state], state))) - rows.append(seg(summary, w - 1)) - if err: - rows.append(seg([(BLOCKED, " ! " + err)], w - 1)) - - wants = counts.get("blocked", 0) + counts.get("done", 0) - if wants: - rows.append(seg([(BLOCKED if counts.get("blocked") else DONE, - " ▸ %d agent%s waiting for you" % - (wants, "" if wants == 1 else "s"))], w - 1)) - else: - rows.append(seg([(DIM, " nothing waiting on you")], w - 1)) - rows.append("") - - wide = w >= 66 - rows.append(LBL + " ── AGENTS ── " + DIM + "%d" % len(agents)) - head = " %-8s %-8s %-6s %-5s" % ("AGENT", "STATE", "FOR", "CPU") - if wide: - head += " %-5s %-18s" % ("MEM", "WORKSPACE") - rows.append(DIM + pad(head, w - 1)) - - visible = max(1, len(entries)) - for i in range(len(agents)): - a = agents[i] - if len(rows) >= h - 6: - break - here = i == selected - state = a.get("agent_status") or "unknown" - col = COLOR.get(state, UNKNOWN) - loud = state in ("blocked", "done") - tint = bg(38, 56, 76) if here else ( - bg(46, 26, 30) if state == "blocked" else ( - bg(22, 46, 34) if state == "done" else "")) - mark = SPINNER[tick % 4] if state == "working" else MARK.get(state, "?") - cpu = a.get("cpu") - line = [(tint + col, ("▸" if here else " ") - + "%s %-6s" % (mark, a.get("agent", "?")[:6])), - (tint + col, " %-8s" % state.upper() if loud else " %-8s" % state), - (tint + DIM, " %-6s" % (("" if a.get("exact") else "≥") - + ago(a.get("since", 0)))), - (tint + (heat(min(1.0, (cpu or 0) / 100.0)) if cpu else DIM), - "%4.0f%%" % cpu if cpu is not None else " -")] - if wide: - place = labels.get(a.get("workspace_id")) or a.get("workspace_id", "") - if not show_labels: - place = a.get("pane_id", "") - line.append((tint + DIM, " " + mem(a.get("rss")))) - line.append((tint + ACCENT, " " + pad(place, 18))) - if loud or here: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - if len(rows) < h - 1: - title_text = (a.get("terminal_title_stripped") or "").strip() - cwd = (a.get("cwd") or "").replace(os.path.expanduser("~/projects/"), "") - rows.append(seg([(tint + DIM, " " + cwd + " "), - (tint + (TXT if (loud or here) else DIM), title_text), - (tint, " " * w if (loud or here) else "")], w - 1)) - if not agents and not err: - rows.append(DIM + " no agents running") - - running = [p for p in panels if not p["idle"]] - idle = [p for p in panels if p["idle"]] - - rows.append("") - rows.append(LBL + " ── PROCESSES ── " + DIM + - "%d pane%s running something" % - (len(running), "" if len(running) == 1 else "s")) - if wide: - rows.append(DIM + pad(" %-20s %-5s %-5s %-18s" % - ("COMMAND", "CPU", "MEM", "WORKSPACE"), w - 1)) - for j, pn in enumerate(running): - if len(rows) >= h - 2: - break - here = (len(agents) + j) == selected - tint = bg(38, 56, 76) if here else "" - cpu = pn.get("cpu") - place = labels.get(pn.get("workspace_id")) or pn.get("workspace_id", "") - if not show_labels: - place = pn.get("pane_id", "") - line = [(tint + PROC, ("▸" if here else " ") + "▪ "), - (tint + TXT, pad(pn.get("command", "?"), 20)), - (tint + (heat(min(1.0, (cpu or 0) / 100.0)) if cpu else DIM), - "%4.0f%%" % cpu if cpu is not None else " -")] - if wide: - line.append((tint + DIM, " " + mem(pn.get("rss")))) - line.append((tint + ACCENT, " " + pad(place, 18))) - if here: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - if not running: - rows.append(DIM + " every other pane is idle at a prompt") - - if show_idle and idle: - rows.append("") - rows.append(LBL + " ── IDLE ── " + DIM + - "%d pane%s at a prompt" % - (len(idle), "" if len(idle) == 1 else "s")) - for j, pn in enumerate(idle): - if len(rows) >= h - 2: - break - here = (len(agents) + len(running) + j) == selected - tint = bg(38, 56, 76) if here else "" - place = labels.get(pn.get("workspace_id")) or pn.get("workspace_id", "") - if not show_labels: - place = pn.get("pane_id", "") - where = (pn.get("cwd") or "").replace( - os.path.expanduser("~/projects/"), "").replace( - os.path.expanduser("~"), "~") - line = [(tint + IDLE_C, ("▸" if here else " ") + "▫ "), - (tint + IDLE_C, pad(tail_path(where, 26), 27)), - (tint + ACCENT, pad(place, 18))] - if here: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - - # The footer must always be the last visible line, so clamp the body - # to the space left for it rather than budgeting inside each section - - # that drifted, and the footer ended up written past the bottom row. - hints = [[(ACCENT, "↑↓"), (DIM, " select")], - [(ACCENT, "↵"), (DIM, " switch to this pane")], - [(DIM, "[o]idle")], [(DIM, "[w]labels")], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]] - footer = [" " + line for line in pack_hints(hints, w - 2)] - reserve = len(footer) + 1 # +1 for the note line - rows = rows[:h - reserve] - while len(rows) < h - reserve: - rows.append("") - rows.append(seg([(DONE if note.startswith("→") else BLOCKED, " " + note)], - w - 1) if note else "") - rows.extend(footer) - draw(rows, w, h) - time.sleep(0.25) - - -main() diff --git a/latency.py b/latency.py deleted file mode 100755 index 616bcf4..0000000 --- a/latency.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Multi-target latency monitor. - -Continuously pings every target, and shows per-target statistics, a per-target -sparkline, a shared log-scale time graph, and a log of loss/spike events. - - python3 latency.py [-i SECONDS] [-c SECONDS] [host ...] - -Keys while running: i cycles the ping interval (0.2/0.5/1/2/5s, applied to -running pings immediately), g cycles the column aggregation, c cycles seconds -per graph column, q quits. - --i sets the ping interval. -g picks how samples sharing a column combine -(median, mean, min, max, p95; median by default, because latency is -right-skewed and a mean lets one spike misrepresent the whole block). --c sets how many seconds each graph column covers; -the default of one column per ping gives the smoothest motion, while a larger -value trades that for a longer visible history. - -Traffic cost: one 98-byte frame each way per target per interval. At the 1.0s -default with 4 targets that is ~0.8 KB/s (~2.8 MB/hour). - -Measures THIS host -> each target. It cannot measure target-to-target legs; -that needs a probe running on the far end. -""" -import collections -import math -import os -import re -import subprocess -import sys -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (cannot_start, missing, RST, Keyboard, cycle, draw, load_config, maybe_help, pad, - rgb, seg, setup, size, title) - -# Defaults are deliberately generic: personal targets belong in config.json, -# which is git-ignored, so this file stays publishable. -_CFG = load_config("latency", { - "hosts": ["1.1.1.1", "8.8.8.8"], - "interval": 0.5, - "seconds_per_column": 0, - "window": 600, - "spike_factor": 3.0, - "aggregate": "median", - "strip_suffixes": [], -}) - -DEFAULT_HOSTS = list(_CFG["hosts"]) -INTERVAL = float(_CFG["interval"]) # seconds between pings; -i overrides -SECONDS_PER_COLUMN = float(_CFG["seconds_per_column"]) -WINDOW = int(_CFG["window"]) # samples retained per target -SPIKE_FACTOR = float(_CFG["spike_factor"]) -STRIP_SUFFIXES = list(_CFG["strip_suffixes"]) - -AGGREGATE = _CFG["aggregate"] # how samples sharing a graph column combine -AGGREGATORS = ("median", "mean", "min", "max", "p95") - -# runtime key bindings cycle through these -INTERVAL_CHOICES = (0.2, 0.5, 1.0, 2.0, 5.0) -COLUMN_CHOICES = (0, 2.0, 5.0, 10.0) -RESTART = threading.Event() # set when INTERVAL changes; readers relaunch ping - -PALETTE = [(90, 220, 255), (255, 170, 80), (140, 255, 160), - (230, 140, 255), (255, 110, 130), (255, 230, 110), - (120, 160, 255), (255, 140, 200), (150, 255, 240)] -DIM = rgb(70, 100, 120) -GRID = rgb(38, 58, 74) -TXT = rgb(215, 235, 250) -LBL = rgb(120, 170, 200) -GOOD = rgb(110, 255, 170) -WARN = rgb(255, 200, 90) -BAD = rgb(255, 95, 105) -SPARK = "▁▂▃▄▅▆▇█" - -TIME_RE = re.compile(r"time[=<]([\d.]+)\s*ms") -IP_RE = re.compile(r"^PING\s+\S+\s+\(([\d.a-fA-F:]+)\)") - - -def short(host): - """Trim configured suffixes so long FQDNs stay readable in a narrow pane.""" - for suffix in STRIP_SUFFIXES: - if host.endswith(suffix): - return host[:-len(suffix)] - return host - - -def fmt_ms(v): - if v is None: - return " -- " - if v < 1.0: - return "%5.0fµs" % (v * 1000.0) - if v < 100: - return "%5.2fms" % v - return "%5.1fms" % v - - -def pct(v): - return "%5.1f%%" % v - - -def aggregate(values, how=None): - """Combine samples that share one graph column. - - Median by default: latency is right-skewed, so a single spike inside a - bucket would drag a mean well above the latency actually experienced most - of the time. - """ - how = how or AGGREGATE - ordered = sorted(values) - n = len(ordered) - if n == 1: - return ordered[0] - if how == "mean": - return sum(ordered) / n - if how == "min": - return ordered[0] - if how == "max": - return ordered[-1] - if how == "p95": - return ordered[min(n - 1, int(n * 0.95))] - mid = n // 2 - return ordered[mid] if n % 2 else (ordered[mid - 1] + ordered[mid]) / 2.0 - - -class Target(object): - def __init__(self, host, palette_rgb): - self.host = host - self.color = rgb(*palette_rgb) - # dimmed variant, used for the min-max spread band behind the line - self.band = rgb(*[int(c * 0.42) for c in palette_rgb]) - self.ip = None - self.samples = collections.deque(maxlen=WINDOW) # (t, rtt|None) - self.lock = threading.Lock() - self.alive = False - self.proc = None # live ping process, so the interval can change - self.restarting = False # True = deliberate relaunch, not an outage - - def add(self, rtt): - with self.lock: - self.samples.append((time.time(), rtt)) - self.alive = rtt is not None - - def snapshot(self): - with self.lock: - return list(self.samples) - - def stats(self): - s = self.snapshot() - got = [r for _, r in s if r is not None] - lost = sum(1 for _, r in s if r is None) - total = len(s) - if not got: - return {"now": None, "avg": None, "min": None, "max": None, - "jit": None, "p95": None, "loss": 100.0 if total else 0.0, - "n": total, "med": None} - ordered = sorted(got) - jit = 0.0 - if len(got) > 1: - jit = sum(abs(got[i] - got[i - 1]) for i in range(1, len(got))) / (len(got) - 1) - return { - "now": s[-1][1], - "avg": sum(got) / len(got), - "min": ordered[0], - "max": ordered[-1], - "jit": jit, - "p95": ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))], - "med": ordered[len(ordered) // 2], - "loss": 100.0 * lost / total if total else 0.0, - "n": total, - } - - -EVENTS = collections.deque(maxlen=40) -EV_LOCK = threading.Lock() - - -def log_event(color, host, kind, detail): - with EV_LOCK: - EVENTS.append((time.strftime("%H:%M:%S"), color, host, kind, detail)) - - -def reader(t): - """Run ping forever, feeding samples into the target. - - Wrapped, because this runs in a daemon thread: if it raises, the thread - vanishes and the host simply stops updating, which reads as a quiet link - rather than as a broken widget. - """ - try: - _reader(t) - except Exception as e: - t.dead = "reader stopped: %s: %s" % (type(e).__name__, str(e)[:50]) - - -def _reader(t): - down_since = None - while True: - try: - proc = subprocess.Popen( - ["ping", "-n", "-O", "-i", str(INTERVAL), t.host], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, bufsize=1) - t.proc = proc - except OSError: - time.sleep(2) - continue - for line in proc.stdout: - m = IP_RE.match(line) - if m: - t.ip = m.group(1) - continue - m = TIME_RE.search(line) - if m: - rtt = float(m.group(1)) - st = t.stats() - if st["med"] and rtt > st["med"] * SPIKE_FACTOR and st["n"] > 10: - log_event(t.color, t.host, "SPIKE", "%s (median %s)" % - (fmt_ms(rtt), fmt_ms(st["med"]))) - if down_since is not None: - log_event(t.color, t.host, "UP", "recovered after %.0fs" % - (time.time() - down_since)) - down_since = None - t.add(rtt) - elif "no answer yet" in line or "Unreachable" in line or "100% packet loss" in line: - if down_since is None: - down_since = time.time() - log_event(t.color, t.host, "LOSS", "no reply") - t.add(None) - proc.wait() - if t.restarting: - # we killed it ourselves to apply a new interval; not an outage - t.restarting = False - continue - # ping exited (bad name, network gone) - record loss and retry - if down_since is None: - down_since = time.time() - log_event(t.color, t.host, "DOWN", "ping exited, retrying") - t.add(None) - time.sleep(2) - - -def apply_interval(targets): - """Restart every ping so a new INTERVAL takes effect immediately.""" - for t in targets: - t.restarting = True - if t.proc and t.proc.poll() is None: - try: - t.proc.terminate() - except OSError: - t.restarting = False - - -def sparkline(samples, n): - pts = samples[-n:] - got = [r for _, r in pts if r is not None] - if not got: - return BAD + "×" * min(n, len(pts)) - lo, hi = min(got), max(got) - span = (hi - lo) or 1.0 - out = [] - last = None - for _, r in pts: - if r is None: - if last != BAD: - out.append(BAD) - last = BAD - out.append("×") - continue - frac = (r - lo) / span - col = GOOD if frac < 0.5 else (WARN if frac < 0.85 else BAD) - if col != last: - out.append(col) - last = col - out.append(SPARK[min(7, int(frac * 7.99))]) - return "".join(out) - - -def build_graph(targets, gw, gh, bucket): - """Log-scale multi-series plot. - - Columns are anchored to a fixed time grid (floor(ts / bucket)) rather than - measured backwards from `now`. A sample therefore never migrates between - columns, so the plot steps left exactly once per bucket instead of - jittering as `now` slides. With bucket == INTERVAL every ping advances the - plot by one column, the finest motion a character grid allows. - - Consecutive samples are joined into a polyline, so each series reads as a - continuous trace rather than scattered dots. - - Column gw-1 is 'now'. Returns (rows, span_seconds). - """ - newest = int(math.floor(time.time() / bucket)) - series = {} - lo = hi = None - for t in targets: - cols = [None] * gw - for ts, r in t.snapshot(): - if r is None: - continue - idx = gw - 1 - (newest - int(math.floor(ts / bucket))) - if 0 <= idx < gw: - if cols[idx] is None: - cols[idx] = [] - cols[idx].append(r) - # each column -> (central value, bucket min, bucket max) - vals = [None if c is None else (aggregate(c), min(c), max(c)) for c in cols] - series[t.host] = vals - for v in vals: - if v is None: - continue - lo = v[1] if lo is None else min(lo, v[1]) - hi = v[2] if hi is None else max(hi, v[2]) - if lo is None: - return [DIM + " collecting…"], bucket * gw - lo = max(0.05, lo * 0.8) - hi = max(hi * 1.25, lo * 1.6) - llo, lhi = math.log10(lo), math.log10(hi) - - grid = [[" "] * gw for _ in range(gh)] - color = [[None] * gw for _ in range(gh)] - - def put(x, y, ch, col): - if 0 <= x < gw and 0 <= y < gh: - grid[y][x] = ch - color[y][x] = col - - def row_of(v): - frac = (math.log10(max(v, 1e-3)) - llo) / (lhi - llo) - return int(round((1.0 - frac) * (gh - 1))) - - # pass 1: min-max spread inside each bucket, dimmed, behind everything - for t in reversed(targets): - for x, v in enumerate(series[t.host]): - if v is None: - continue - top, bot = row_of(v[2]), row_of(v[1]) - if top == bot: - continue # spread smaller than one row - for y in range(min(top, bot), max(top, bot) + 1): - put(x, y, "│", t.band) - - # pass 2: the central-value polyline, drawn over the bands - for t in reversed(targets): # first host in list drawn last (wins) - pts = [(x, row_of(v[0])) for x, v in enumerate(series[t.host]) if v is not None] - for i, (x, y) in enumerate(pts): - if i: - x0, y0 = pts[i - 1] - prev = y0 - for xx in range(x0 + 1, x + 1): - f = (xx - x0) / float(x - x0) - yy = int(round(y0 + (y - y0) * f)) - for k in range(min(prev, yy), max(prev, yy) + 1): - put(xx, k, "·" if k == yy else "│", t.color) - prev = yy - put(x, y, "●", t.color) - - rows = [] - for y in range(gh): - frac = 1.0 - y / float(gh - 1) if gh > 1 else 1.0 - tick = 10 ** (llo + frac * (lhi - llo)) - label = fmt_ms(tick) if y % 3 == 0 else " " - parts = [(LBL, label), (GRID, "│")] - run_col, run = None, [] - for x in range(gw): - c = color[y][x] - ch = grid[y][x] - if ch == " ": - ch = "·" if (x % 12 == 0 and y % 3 == 0) else " " - c = GRID if ch != " " else None - if c != run_col: - if run: - parts.append((run_col or RST, "".join(run))) - run_col, run = c, [] - run.append(ch) - if run: - parts.append((run_col or RST, "".join(run))) - rows.append(seg(parts, gw + 8)) - return rows, bucket * gw - - -def main(): - maybe_help(__doc__) - global INTERVAL, SECONDS_PER_COLUMN, AGGREGATE - args = sys.argv[1:] - while args and args[0] in ("-i", "--interval", "-c", "--column-seconds", - "-g", "--group"): - if args[0] in ("-i", "--interval"): - INTERVAL = max(0.2, float(args[1])) - elif args[0] in ("-c", "--column-seconds"): - SECONDS_PER_COLUMN = max(0.0, float(args[1])) - else: - if args[1] not in AGGREGATORS: - raise SystemExit("-g must be one of: " + ", ".join(AGGREGATORS)) - AGGREGATE = args[1] - args = args[2:] - hosts = args or DEFAULT_HOSTS - targets = [Target(h, PALETTE[i % len(PALETTE)]) for i, h in enumerate(hosts)] - absent = missing("ping") - if absent: - cannot_start( - "latency", absent, - ["Every figure here comes from ping: this widget times replies,", - "it does not send packets itself. With no ping there is nothing", - "to time and nothing to draw.", - "", - "It is in iputils-ping on Debian and Ubuntu, and in iputils on", - "Fedora and Arch."], - "apt install iputils-ping") - - setup() - keyboard = Keyboard() - for t in targets: - th = threading.Thread(target=reader, args=(t,)) - th.daemon = True - th.start() - - while True: - for key in keyboard.poll(): - if key in ("q", "Q"): - keyboard.restore() - raise SystemExit(0) - if key == "i": - INTERVAL = cycle(INTERVAL_CHOICES, INTERVAL) - apply_interval(targets) - elif key == "g": - AGGREGATE = cycle(AGGREGATORS, AGGREGATE) - elif key == "c": - SECONDS_PER_COLUMN = cycle(COLUMN_CHOICES, SECONDS_PER_COLUMN) - - w, h = size() - bucket = SECONDS_PER_COLUMN or INTERVAL - rows = [title("network latency monitor", w, rgb(90, 220, 255))] - rows.append(seg([(DIM, " %d targets · %.1fs interval · " % (len(targets), INTERVAL)), - (TXT, time.strftime("%H:%M:%S")), - (DIM, " · " + ("1 ping/column" if bucket <= INTERVAL - else "%s of %gs blocks" % (AGGREGATE, bucket))), - (GRID, " [i]nterval [g]roup [c]olumns [q]uit" - if keyboard.fd is not None else "")], - w - 1)) - rows.append("") - wide = w >= 72 - show_med = w >= 80 - head = " %-22s %7s %7s%s %7s %7s %7s %6s" % ( - "HOST", "NOW", "AVG", " MEDIAN" if show_med else "", - "MIN", "MAX", "JITTER", "LOSS") - rows.append(LBL + pad(head, w - 1)) - for t in targets: - st = t.stats() - dot = GOOD + "●" if t.alive else BAD + "○" - name = short(t.host) - lossc = GOOD if st["loss"] == 0 else (WARN if st["loss"] < 5 else BAD) - rows.append(seg([(dot, " "), (t.color, pad(name, 22)), - (TXT, " " + fmt_ms(st["now"])), - (TXT, " " + fmt_ms(st["avg"])), - (GOOD, (" " + fmt_ms(st["med"])) if show_med else ""), - (DIM, " " + fmt_ms(st["min"])), - (DIM, " " + fmt_ms(st["max"])), - (TXT, " " + fmt_ms(st["jit"])), - (lossc, " " + pct(st["loss"]))], w - 1)) - if wide: - rows.append(" " + sparkline(t.snapshot(), w - 6)) - rows.append("") - - ev_h = 7 if h - len(rows) > 20 else 0 - gh = max(4, h - len(rows) - ev_h - 4) - gw = max(10, w - 10) - graph, gspan = build_graph(targets, gw, gh, bucket) - rows.extend(graph) - rows.append(LBL + " └" + GRID + "─" * gw) - # The plot occupies columns 8 .. 8+gw-1, so the axis labels must span - # exactly gw cells: oldest flush left, "now" flush right under the - # newest sample. - left = "%ds ago" % int(gspan) - if len(left) + 4 > gw: - left = "" - rows.append(DIM + " " * 8 + left + " " * (gw - len(left) - 3) + "now") - rows.append("") - - if ev_h: - rows.append(DIM + " ── EVENTS ──") - with EV_LOCK: - evs = list(EVENTS)[-(ev_h - 1):] - if not evs: - rows.append(DIM + " (no loss or spikes recorded)") - for ts, col, host, kind, detail in evs: - kc = BAD if kind in ("LOSS", "DOWN") else (WARN if kind == "SPIKE" else GOOD) - rows.append(seg([(DIM, " " + ts + " "), (kc, "%-6s" % kind), - (col, pad(short(host), 22)), - (DIM, detail)], w - 1)) - draw(rows, w, h) - time.sleep(0.5) - - -main() diff --git a/linear.py b/linear.py deleted file mode 100755 index 8f52672..0000000 --- a/linear.py +++ /dev/null @@ -1,710 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Linear delivery metrics across every team in the workspace. - -What is outstanding, what the running cycles look like, and whether issues are -being closed faster than they arrive. - - python3 linear.py [-n SECONDS] [team-key ...] - -Team keys are the prefixes on issue identifiers - XFY, SYS and so on. With -none given every team is included, minus anything in `linear.exclude_teams`. - -Triage is counted apart from the backlog throughout. An auto-filed intake -queue and a groomed backlog are different populations, and adding them -together produces a number that means nothing. - -Credentials: `linear.token` in config.json, or $LINEAR_API_KEY. A personal API -key from Settings - Security & access - Personal API keys. The API is called -directly, so no CLI is required. - -Keys: up/down select a team, r refreshes now, w cycles the window -(7/14/30/60/90 days), q quits. -""" -import collections -import datetime -import json -import os -import sys -import threading -import time -import urllib.error -import urllib.request - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (RST, Keyboard, bg, config_token_warning, cycle, dance, - draw, heat, load_config, maybe_help, meter, mix, - pack_hints, pad, rgb, seg, setup, size, skeleton, - stacked_bar, title, vbars, vbars_down) - -_CFG = load_config("linear", { - "token": "", - "token_env": "LINEAR_API_KEY", - "exclude_teams": [], # team keys to drop, e.g. an automated intake queue - "window_days": 14, - "refresh": 120, # seconds; the limit is 2500 requests an hour -}) - -REFRESH = float(_CFG["refresh"]) -WINDOWS = (7, 14, 30, 60, 90) -API = "https://api.linear.app/graphql" -PAGE = 250 # Linear's maximum page size -PAGE_CAP = 12 # pages per query, so one huge team cannot spin forever - -OK = rgb(90, 240, 160) -WARN = rgb(255, 200, 90) -BAD = rgb(255, 100, 110) -DIM = rgb(127, 147, 172) -GRID = rgb(60, 78, 98) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) -NEW = rgb(180, 160, 255) # issues arriving -NEW_RGB, OK_RGB = (180, 160, 255), (90, 240, 160) -GHOST = (96, 106, 124) -LOAD_NEW, LOAD_OK = mix(GHOST, NEW_RGB, 0.45), mix(GHOST, OK_RGB, 0.45) -SETTLE_FRAMES = 8 -CHURN_DAYS = 6 # tail of a cycle's history that counts as "lately" - -# Linear's own vocabulary, in the order work moves through it -STATE_ORDER = ("triage", "backlog", "unstarted", "started") -STATE_LABEL = {"triage": "triage", "backlog": "backlog", - "unstarted": "todo", "started": "in progress"} -STATE_COLOUR = {"triage": BAD, "backlog": DIM, "unstarted": ACCENT, - "started": WARN} - - -def token(): - """A Linear personal API key, from config.json or the environment.""" - if _CFG["token"]: - return _CFG["token"], "config" - tok = os.environ.get(_CFG["token_env"] or "LINEAR_API_KEY") - if tok: - return tok, "env" - return None, "missing" - - -_QUOTA = {"requests": None, "complexity": None} - - -def graphql(query, tok, variables=None): - body = json.dumps({"query": query, - "variables": variables or {}}).encode() - req = urllib.request.Request(API, data=body, headers={ - "Authorization": tok, - "Content-Type": "application/json", - "User-Agent": "terminal-toys", - }) - with urllib.request.urlopen(req, timeout=30) as r: - for key, hdr in (("requests", "X-RateLimit-Requests-Remaining"), - ("complexity", "X-RateLimit-Complexity-Remaining")): - raw = r.headers.get(hdr) - if raw is not None: - try: - _QUOTA[key] = int(raw) - except ValueError: - pass - data = json.load(r) - if data.get("errors"): - raise ValueError(data["errors"][0].get("message", "")[:80]) - return data["data"] - - -def pages(tok, query, node_path, variables=None): - """Follow pageInfo to the end, or to PAGE_CAP, and return every node. - - Linear has no totalCount on connections, so anything counted has to be - walked. Only the fields actually needed are requested: complexity is a - tenth of a point per property against a budget of three million an hour, - so the page count matters and the field count barely does. - """ - out, cursor, seen = [], None, 0 - for _ in range(PAGE_CAP): - v = dict(variables or {}) - v["after"] = cursor - conn = graphql(query, tok, v) - for step in node_path: - conn = conn[step] - out.extend(conn["nodes"]) - info = conn["pageInfo"] - seen += 1 - if not info.get("hasNextPage"): - return out, False - cursor = info.get("endCursor") - return out, True # hit the cap: the caller should say so - - -OPEN_QUERY = """ -query($after: String) { - issues(first: %d, after: $after, - filter: { state: { type: { nin: ["completed", "canceled", - "duplicate"] } } }) { - nodes { identifier estimate startedAt createdAt - state { type } team { key } } - pageInfo { hasNextPage endCursor } - } -}""" % PAGE - -CREATED_QUERY = """ -query($after: String, $since: DateTimeOrDuration!) { - issues(first: %d, after: $after, filter: { createdAt: { gte: $since } }) { - nodes { createdAt team { key } } - pageInfo { hasNextPage endCursor } - } -}""" % PAGE - -DONE_QUERY = """ -query($after: String, $since: DateTimeOrDuration!) { - issues(first: %d, after: $after, filter: { completedAt: { gte: $since } }) { - nodes { identifier completedAt startedAt createdAt team { key } } - pageInfo { hasNextPage endCursor } - } -}""" % PAGE - -CYCLES_QUERY = """ -{ - cycles(first: 50, filter: { isActive: { eq: true } }) { - nodes { - name number startsAt endsAt progress - issueCountHistory completedIssueCountHistory - scopeHistory completedScopeHistory - team { key name } - } - pageInfo { hasNextPage endCursor } - } -}""" - -TEAMS_QUERY = """ -{ teams(first: 100) { nodes { key name } pageInfo { hasNextPage } } }""" - - -def day(ts): - """The calendar day of an ISO timestamp, as Linear returns them.""" - return (ts or "")[:10] - - -def ago(t): - if not t: - return "--" - s = int(time.time() - t) - if s < 60: - return "%ds" % s - if s < 3600: - return "%dm" % (s // 60) - return "%dh" % (s // 3600) - - -class Store(object): - def __init__(self, days, keep): - self.lock = threading.Lock() - self.days = days - self.keep = keep # team keys to include; empty = everything - self.teams = [] - self.states = collections.Counter() - self.by_team = {} - self.cycles = [] - self.created = collections.Counter() - self.completed = collections.Counter() - self.lead = [] # created -> completed, in hours - self.cycle_time = [] # started -> completed, in hours - # extremes, each as (hours, identifier): a median says the shape of - # the distribution, these say which issue to go and look at - self.quickest = self.slowest = None - self.oldest_open = self.oldest_wip = None - self.window = None # which window the counters describe - self.truncated = False - self.error = None - self.fetched = 0 - self.wake = threading.Event() - - def snapshot(self): - with self.lock: - return (list(self.teams), collections.Counter(self.states), - dict(self.by_team), list(self.cycles), - collections.Counter(self.created), - collections.Counter(self.completed), list(self.lead), - list(self.cycle_time), - (self.quickest, self.slowest, self.oldest_open, - self.oldest_wip), - self.window, self.truncated, self.error, self.fetched) - - def wanted(self, key): - if self.keep: - return key in self.keep - return key not in (_CFG["exclude_teams"] or []) - - def run(self): - # A daemon thread that raises just stops, and a dead poller looks - # exactly like a source with no data - which is how deployments.py - # showed "0 deploys" for a day after an import went missing. - try: - self.poll() - except Exception as e: - with self.lock: - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:70]) - - def poll(self): - while True: - tok, source = token() - if not tok: - with self.lock: - self.error = ("no key: set linear.token in config.json or " - "$%s" % (_CFG["token_env"] or "LINEAR_API_KEY")) - self.wake.wait(REFRESH) - self.wake.clear() - continue - try: - self.pass_(tok, source) - except urllib.error.HTTPError as e: - with self.lock: - self.error = "HTTP %s from Linear%s" % ( - e.code, " (key rejected?)" if e.code == 401 else "") - except Exception as e: - with self.lock: - self.error = "%s: %s" % (type(e).__name__, str(e)[:60]) - self.wake.wait(REFRESH) - self.wake.clear() - - def pass_(self, tok, source): - with self.lock: - days_now = self.days - since = (datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(days=days_now - 1)).strftime( - "%Y-%m-%dT00:00:00.000Z") - - teams = graphql(TEAMS_QUERY, tok)["teams"]["nodes"] - teams = [t for t in teams if self.wanted(t["key"])] - keys = set(t["key"] for t in teams) - with self.lock: - self.teams = teams - - # what is outstanding right now, at any age - rows, capped = pages(tok, OPEN_QUERY, ["issues"]) - states = collections.Counter() - by_team = {} - now = datetime.datetime.now(datetime.timezone.utc) - oldest_open = oldest_wip = None - for it in rows: - key = (it.get("team") or {}).get("key") - if key not in keys: - continue - st = (it.get("state") or {}).get("type") - if st not in STATE_ORDER: - continue - states[st] += 1 - slot = by_team.setdefault(key, collections.Counter()) - slot[st] += 1 - slot["open"] += 1 - born = parse(it.get("createdAt")) - if born: - age = (now - born).total_seconds() / 3600.0 - if oldest_open is None or age > oldest_open[0]: - oldest_open = (age, it.get("identifier")) - began = parse(it.get("startedAt")) - if st == "started" and began: - age = (now - began).total_seconds() / 3600.0 - if oldest_wip is None or age > oldest_wip[0]: - oldest_wip = (age, it.get("identifier")) - - # the running cycles, each already carrying its own burndown - cyc = [c for c in graphql(CYCLES_QUERY, tok)["cycles"]["nodes"] - if (c.get("team") or {}).get("key") in keys] - - # arrivals and departures over the window - made, cap2 = pages(tok, CREATED_QUERY, ["issues"], {"since": since}) - done, cap3 = pages(tok, DONE_QUERY, ["issues"], {"since": since}) - created, completed = collections.Counter(), collections.Counter() - lead, ctime = [], [] - quickest = slowest = None - for it in made: - if (it.get("team") or {}).get("key") in keys: - created[day(it["createdAt"])] += 1 - for it in done: - if (it.get("team") or {}).get("key") not in keys: - continue - completed[day(it["completedAt"])] += 1 - fin = parse(it.get("completedAt")) - if fin and parse(it.get("createdAt")): - hrs = (fin - parse(it["createdAt"])).total_seconds() / 3600.0 - lead.append(hrs) - if quickest is None or hrs < quickest[0]: - quickest = (hrs, it.get("identifier")) - if slowest is None or hrs > slowest[0]: - slowest = (hrs, it.get("identifier")) - if fin and parse(it.get("startedAt")): - ctime.append((fin - parse(it["startedAt"])).total_seconds() / 3600.0) - - for key in by_team: - by_team[key]["done"] = sum( - 1 for it in done - if (it.get("team") or {}).get("key") == key) - - with self.lock: - self.states, self.by_team, self.cycles = states, by_team, cyc - self.created, self.completed = created, completed - self.lead, self.cycle_time = lead, ctime - self.quickest, self.slowest = quickest, slowest - self.oldest_open, self.oldest_wip = oldest_open, oldest_wip - self.window = days_now - self.truncated = capped or cap2 or cap3 - self.fetched = time.time() - self.error = (config_token_warning() if source == "config" else None) - - -def parse(ts): - if not ts: - return None - try: - return datetime.datetime.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S").replace( - tzinfo=datetime.timezone.utc) - except ValueError: - return None - - -def dur(hours): - """A span at whatever unit keeps it readable. - - Rolls over to years because these figures reach them: an issue open for - "1021.6d" is arithmetic, one open for "2.8y" is a decision. - """ - if hours is None: - return "--" - if hours < 1: - return "%dm" % max(1, int(hours * 60)) - if hours < 48: - return "%.1fh" % hours - days = hours / 24.0 - if days < 365: - return "%.1fd" % days - return "%.1fy" % (days / 365.0) - - -def median(xs): - if not xs: - return None - s = sorted(xs) - n = len(s) - return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2.0 - - -def main(): - maybe_help(__doc__) - args = sys.argv[1:] - while args and args[0] in ("-n", "--refresh"): - global REFRESH - REFRESH = float(args[1]) - args = args[2:] - store = Store(int(_CFG["window_days"]), [a.upper() for a in args]) - threading.Thread(target=store.run, daemon=True).start() - setup() - keyboard = Keyboard() - # Two sections scroll, so the arrows need to know which one they are in. - # Tab moves the focus; the focused heading says so and carries the range. - CYCLES, TEAMS = 0, 1 - focus, sel = CYCLES, [0, 0] - tick = 0 - settle_t, settle_from = 0, None - - while True: - tick += 1 - for key in keyboard.poll(): - if key in ("q", "Q"): - raise SystemExit(0) - if key == "r": - store.wake.set() - elif key == "w": - with store.lock: - store.days = cycle(WINDOWS, store.days) - store.wake.set() - elif key == "tab": - focus = TEAMS if focus == CYCLES else CYCLES - elif key == "up": - sel[focus] = max(0, sel[focus] - 1) - elif key == "down": - sel[focus] += 1 - - w, h = size() - (teams, states, by_team, cycles, created, completed, lead, ctime, - extremes, window, truncated, err, fetched) = store.snapshot() - quickest, slowest, oldest_open, oldest_wip = extremes - stale = window != store.days - rows = [title("linear ops", w, NEW)] - - head = [(DIM, " %d team%s" % (len(teams), "" if len(teams) == 1 else "s")), - (DIM, " updated %s ago" % ago(fetched))] - if _QUOTA["requests"] is not None: - left = _QUOTA["requests"] - head.append((OK if left > 500 else WARN, - " %d req left/hr" % left)) - rows.append(seg(head, w - 1)) - if err: - rows.append(seg([(BAD, " ! " + err)], w - 1)) - if not teams: - rows.append(seg([(DIM, " collecting…")], w - 1)) - rows += [""] * max(0, h - len(rows) - 1) - draw(rows, w, h) - time.sleep(0.4) - continue - - # ── how long work takes, across every team ────────────────────── - # Leads the board: it is the one figure that says whether the machine - # is getting faster or slower, and it is an aggregate over all teams - # rather than any one of them - which the heading has to say, or it - # reads as whichever team happens to be selected below. - med_lead, med_cycle = median(lead), median(ctime) - rows.append(seg([(LBL, " ── HOW LONG ── "), - (DIM, "all teams · "), - (DIM, "counting…" if stale - else "median of %d completed in %dd" - % (len(lead), store.days))], w - 1)) - # Every figure here goes through one grid. The medians used to be - # hand-padded and drifted out of step with the extremes beneath them, - # and the arrow definitions floated after the values instead of - # attaching to the terms they define. - def extreme(label, pair, colour): - if stale: - return (label, "···", DIM) - if not pair: - return (label, "--", DIM) - hours, ident = pair - return (label, "%s %s" % (ident or "?", dur(hours)), colour) - - cells = [ - ("lead (created→completed)", - "···" if stale else dur(med_lead), DIM if stale else TXT), - ("cycle (started→completed)", - "···" if stale else dur(med_cycle), DIM if stale else TXT), - extreme("quickest", quickest, OK), - extreme("slowest", slowest, WARN), - extreme("oldest open", oldest_open, BAD), - extreme("oldest in progress", oldest_wip, WARN), - ] - label_w = max(len(x[0]) for x in cells) - # Two columns only when a value still gets room for the longest thing - # it holds - an identifier and a duration. Cells are a fixed width so - # a long value cannot push the next column out of line. - ncols = 2 if (w - 2) // 2 - label_w - 3 >= 15 else 1 - cw = (w - 2) // ncols - val_w = max(6, cw - label_w - 3) - for n in range(0, len(cells), ncols): - line = [(RST, " ")] - for label, value, colour in cells[n:n + ncols]: - line += [(DIM, " " + pad(label, label_w) + " "), - (colour, pad(value, val_w))] - rows.append(seg(line, w - 1)) - rows.append("") - - # ── what is outstanding right now ──────────────────────────────── - total_open = sum(states[s] for s in STATE_ORDER) - rows.append(seg([(LBL, " ── OPEN ── "), - (NEW, "%d" % total_open), (DIM, " issues open"), - (DIM, " (any age)"), - (WARN, " truncated" if truncated else "")], w - 1)) - if total_open: - parts = [(states[s] / float(total_open), STATE_COLOUR[s]) - for s in STATE_ORDER if states[s]] - rows.append(seg([(RST, " ")] + stacked_bar(parts, max(10, w - 3)), - w - 1)) - key = [(RST, " ")] - for s in STATE_ORDER: - if states[s]: - key += [(STATE_COLOUR[s], "▇ "), (TXT, STATE_LABEL[s]), - (DIM, " %d (%.0f%%) " - % (states[s], 100.0 * states[s] / total_open))] - rows.append(seg(key, w - 1)) - - # ── the running cycles, each with its own burndown ─────────────── - rows.append("") - # Busiest first. The burndown arrays already say where the action is: - # day-over-day movement in completed scope and in scope itself, summed - # over the tail. A cycle nothing has touched in a week is not - # interesting however close its deadline, and an empty one scores zero - # and sinks without needing a special case. Deadline breaks ties. - def churn(c): - moved = 0.0 - for series in ("completedScopeHistory", "scopeHistory"): - tail = (c.get(series) or [])[-CHURN_DAYS:] - moved += sum(abs(tail[i] - tail[i - 1]) - for i in range(1, len(tail))) - ends = parse(c.get("endsAt")) - left = ((ends - datetime.datetime.now(datetime.timezone.utc)).days - if ends else 999) - return (-moved, left) - - ranked_cycles = sorted(cycles, key=churn) - if ranked_cycles: - sel[CYCLES] = max(0, min(sel[CYCLES], len(ranked_cycles) - 1)) - shown = max(2, min(6, (h - len(rows)) // 4)) - cfirst = 0 - if len(ranked_cycles) > shown: - cfirst = min(max(0, sel[CYCLES] - shown // 2), - len(ranked_cycles) - shown) - here_now = focus == CYCLES - rows.append(seg([(ACCENT if here_now else LBL, " ── ACTIVE CYCLES ── "), - (DIM, "%d running" % len(cycles)), - (ACCENT if here_now else DIM, - (" %s%d-%d of %d" - % ("↑↓ " if here_now else "", - cfirst + 1, - min(cfirst + shown, len(ranked_cycles)), - len(ranked_cycles))) - if len(ranked_cycles) > shown else "")], w - 1)) - if not cycles: - rows.append(seg([(DIM, " no cycle is running in any team")], w - 1)) - for ci, c in list(enumerate(ranked_cycles))[cfirst:cfirst + shown]: - scope = (c.get("scopeHistory") or [0])[-1] - done = (c.get("completedScopeHistory") or [0])[-1] - opened_at = (c.get("scopeHistory") or [0])[0] - ends = parse(c.get("endsAt")) - left = ((ends - datetime.datetime.now(datetime.timezone.utc)).days - if ends else None) - frac = (done / float(scope)) if scope else 0.0 - name = "%s %s" % ((c.get("team") or {}).get("key", "?"), - c.get("name") or ("Cycle %g" % (c.get("number") or 0))) - on = focus == CYCLES and ci == sel[CYCLES] - tint = bg(38, 56, 76) if on else "" - line = [(tint + (ACCENT if on else TXT), - ("▸" if on else " ") + pad(name, 18)), - (tint + heat(frac), meter(frac, max(8, min(28, w - 54)))), - (tint + (heat(frac) if scope else DIM), - " %3s" % ("%.0f%%" % (frac * 100) if scope else "--")), - (tint + DIM, " %g/%g pts" % (done, scope) if scope - else " nothing scoped")] - if left is not None: - line.append((tint + (WARN if left <= 2 else DIM), - " %dd left" % left)) - # scope added after the cycle opened is the number that explains a - # cycle that is working hard and still slipping - if scope > opened_at: - line.append((tint + BAD, " +%g added" % (scope - opened_at))) - if on: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - - # ── arrivals against departures ───────────────────────────────── - hist_days = store.days - today = datetime.date.today() - days = [(today - datetime.timedelta(days=n)).isoformat() - for n in range(hist_days - 1, -1, -1)] - avail = max(10, w - 3) - if len(days) > avail: - days = days[-avail:] - slot = max(1, avail // len(days)) - gap = 1 if slot >= 3 else 0 - barw = slot - gap - - def spread(per_day): - cols = [] - for n, v in enumerate(per_day): - cols.extend([v] * barw) - if gap and n < len(per_day) - 1: - cols.extend([0] * gap) - return cols - - made_day = [created.get(d, 0) for d in days] - done_day = [completed.get(d, 0) for d in days] - up, down = spread(made_day), spread(done_day) - chart_cols = len(up) - span_hi = max(up + down) or 1 - span = ("%dd of %dd" % (len(days), hist_days) - if len(days) < hist_days else "%dd" % len(days)) - rows.append("") - if stale: - rows.append(seg([(LBL, " ── ISSUE FLOW ── "), - (DIM, "counting %dd…" % hist_days)], w - 1)) - else: - rows.append(seg([(LBL, " ── ISSUE FLOW ── "), (DIM, "%s · " % span), - (NEW, "▲ %d created" % sum(made_day)), - (DIM, " · "), - (OK, "▼ %d completed" % sum(done_day)), - (DIM, " peak %d/day" % span_hi)], w - 1)) - if stale: - hu = spread(dance(len(days), tick)) - hd = spread(dance(len(days), tick, phase=2.1)) - cu, cd = LOAD_NEW, LOAD_OK - settle_from, settle_t = (hu, hd), 0 - else: - real_u = [v / float(span_hi) for v in up] - real_d = [v / float(span_hi) for v in down] - if (settle_from and settle_t < SETTLE_FRAMES - and len(settle_from[0]) == chart_cols): - settle_t += 1 - q = settle_t / float(SETTLE_FRAMES) - q = q * q * (3 - 2 * q) - hu = [a + (b - a) * q for a, b in zip(settle_from[0], real_u)] - hd = [a + (b - a) * q for a, b in zip(settle_from[1], real_d)] - cu = mix(GHOST, NEW_RGB, 0.45 + 0.55 * q) - cd = mix(GHOST, OK_RGB, 0.45 + 0.55 * q) - else: - hu, hd, cu, cd = real_u, real_d, NEW, OK - for line in vbars([(v, cu) for v in hu], 3, hi=1.0): - rows.append(seg([(RST, " ")] + line, w - 1)) - rows.append(seg([(RST, " "), (GRID, "─" * chart_cols)], w - 1)) - for line in vbars_down([(v, cd) for v in hd], 3, hi=1.0): - rows.append(seg([(RST, " ")] + line, w - 1)) - left_lbl = "%dd ago" % len(days) - rows.append(seg([(DIM, " " + left_lbl), - (DIM, " " * max(1, chart_cols - len(left_lbl) - 5)), - (DIM, "today")], w - 1)) - - # ── by team ───────────────────────────────────────────────────── - rows.append("") - ranked = sorted(teams, key=lambda t: ( - -(by_team.get(t["key"], {}).get("open", 0)), t["key"])) - if ranked: - sel[TEAMS] = max(0, min(sel[TEAMS], len(ranked) - 1)) - room = max(1, h - 5 - len(rows)) - first = 0 - if len(ranked) > room: - first = min(max(0, sel[TEAMS] - room // 2), len(ranked) - room) - on_teams = focus == TEAMS - counter = (" %s%d-%d of %d" - % ("↑↓ " if on_teams else "", first + 1, - min(first + room, len(ranked)), len(ranked)) - if len(ranked) > room else "") - rows.append(seg([(ACCENT if on_teams else LBL, " ── BY TEAM ──"), - (ACCENT if on_teams else DIM, counter)], w - 1)) - rows.append(DIM + pad(" %-22s%6s%7s%8s%8s" - % ("TEAM", "OPEN", "TRIAGE", "DOING", "DONE%dD" - % store.days), w - 1)) - for i, t in list(enumerate(ranked))[first:first + room]: - c = by_team.get(t["key"], collections.Counter()) - here = on_teams and i == sel[TEAMS] - tint = bg(38, 56, 76) if here else "" - rows.append(seg([ - (tint + (ACCENT if here else TXT), - ("▸" if here else " ") + pad("%s %s" % (t["key"], t["name"]), 22)), - (tint + NEW, "%6d" % c.get("open", 0)), - (tint + (BAD if c.get("triage") else DIM), "%7d" % c.get("triage", 0)), - (tint + (WARN if c.get("started") else DIM), "%8d" % c.get("started", 0)), - (tint + (OK if c.get("done") else DIM), "%8d" % c.get("done", 0)), - ] + ([(tint, " " * w)] if here else []), w - 1)) - - hints = [[(ACCENT, "↑↓"), (DIM, " scroll")], - [(DIM, "[tab] section")], [(DIM, "[w]indow")], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]] - footer = [" " + line for line in pack_hints(hints, w - 2)] - rows = rows[:h - len(footer)] - while len(rows) < h - len(footer): - rows.append("") - rows.extend(footer) - draw(rows, w, h) - time.sleep(0.3) - - -main() diff --git a/link.py b/link.py deleted file mode 100755 index 0308502..0000000 --- a/link.py +++ /dev/null @@ -1,778 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""How good the connection is between here and whoever is connected to it. - -Every other network widget in this repo measures a path it chose - ping these -hosts, watch that tailnet. This one measures the path *you* are on: the TCP -socket carrying your session, as the kernel already sees it. - -Nothing is sent. `ss -tin` reports what the kernel has measured for each -established socket - round-trip time and its variance, the best round trip it -has ever seen, retransmitted bytes, the delivery rate it actually achieved - -so this widget can describe the link without adding a single packet to it. - - python3 link.py [-n SECONDS] - -Sessions are every established connection into a port this machine listens on, -which is SSH and anything else that accepts terminals. - -w cycles how much time the chart covers - a minute, five, fifteen, an hour. -Past a minute there are more samples than columns, so each column becomes the -median of its slice: a spike is still counted in the worst column and on the -detail screen, but the line itself smooths. Look at a stall on the short -window. - -Keys: up/down select, enter opens one, w changes the span, o toggles idle -sessions, r refreshes, q quits. -""" -import collections -import os -import re -import subprocess -import sys -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (RST, Keyboard, bg, cannot_start, draw, heat, load_config, - maybe_help, pack_hints, pad, rgb, seg, setup, size, - title, vbars) - -_CFG = load_config("link", { - # Every established connection into a port we listen on. Naming ports - # instead pins the set - useful if something else on this machine accepts - # connections you would rather not watch. - "ports": [], - "refresh": 2, - "history": 120, - # The spans w cycles through, in seconds. The first is what opens. - "windows": [60, 300, 900, 3600], -}) - -REFRESH = max(0.5, float(_CFG["refresh"])) -PORTS = [int(p) for p in (_CFG["ports"] or [])] -WINDOWS = [int(s) for s in (_CFG["windows"] or []) if int(s) > 0] or [300] -# Retention has to cover the longest span on offer, or w would cycle to a -# window the samples could never fill. `history` stays a floor rather than -# the figure, so a config asking for more than that still gets it. -HISTORY = max(int(_CFG["history"]), int(max(WINDOWS) / REFRESH) + 2) - -OK = rgb(90, 240, 160) -WARN = rgb(255, 200, 90) -BAD = rgb(255, 100, 110) -DIM = rgb(127, 147, 172) -GRID = rgb(60, 78, 98) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) -LINK = rgb(140, 200, 255) -# One hue per session, distinct from each other and clear of the amber and -# red this widget keeps for trouble. -SESSION_HUES = (rgb(120, 200, 255), rgb(150, 230, 180), rgb(220, 170, 255), - rgb(160, 190, 240), rgb(200, 220, 150), rgb(240, 180, 210)) - -IDLE_AFTER = 300.0 # seconds without traffic before a session is idle -SPARK = "▁▂▃▄▅▆▇█" - - -def run(args): - try: - out = subprocess.run(args, capture_output=True, text=True, timeout=5) - except (OSError, subprocess.SubprocessError): - return "" - return out.stdout if out.returncode == 0 else "" - - -def listening_ports(): - """Ports this machine accepts connections on. - - Inbound is defined as "arrived at a port we listen on" rather than by a - list of port numbers, so SSH, a terminal server and anything else that - accepts sessions are all found without being named. - """ - ports = set(PORTS) - for line in run(["ss", "-tlnH"]).splitlines(): - cols = line.split() - if len(cols) >= 4: - try: - ports.add(int(cols[3].rsplit(":", 1)[1])) - except (ValueError, IndexError): - continue - return ports - - -def parse_metrics(text): - """The kernel's own numbers for one socket. - - `ss` mixes two shapes on that line: `key:value` pairs and space-separated - ones like `delivery_rate 45107960bps`. Both are read; anything unknown is - left alone rather than guessed at. - """ - out = {} - for key in ("send", "pacing_rate", "delivery_rate"): - found = re.search(r"\b%s (\d+)bps" % key, text) - if found: - out[key] = int(found.group(1)) - for token in text.split(): - if ":" not in token: - continue - key, _, value = token.partition(":") - out[key] = value - return out - - -def num(value): - try: - return float(value) - except (TypeError, ValueError): - return None - - -def sessions(): - """One entry per established inbound connection, with its metrics.""" - ports = listening_ports() - if not ports: - return [] - text = run(["ss", "-tinH", "state", "established"]) - if not text: - return [] - found, head = [], None - for line in text.splitlines(): - if not line.startswith(("\t", " ")): - head = line.split() - continue - if head is None or len(head) < 4: - continue - local, peer = head[2], head[3] - try: - lport = int(local.rsplit(":", 1)[1]) - except (ValueError, IndexError): - head = None - continue - peer_ip = peer.rsplit(":", 1)[0].strip("[]") - # ::ffff:10.0.0.1 is an IPv4 address wearing an IPv6 hat - the same - # machine, the same session - so it is unwrapped before anything - # else looks at it. Left wrapped, ::ffff:127.0.0.1 walked straight - # past the loopback filter and put a 22-microsecond local socket on - # the chart, which flattened every real session against the ceiling. - if peer_ip.startswith("::ffff:"): - peer_ip = peer_ip[7:] - if lport not in ports or peer_ip.startswith(("127.", "::1")): - head = None - continue - m = parse_metrics(line) - rtt = (m.get("rtt") or "").split("/") - found.append({ - "peer": "%s:%s" % (peer_ip, peer.rsplit(":", 1)[1]), - "ip": peer_ip, "port": lport, - "rtt": num(rtt[0]) if rtt else None, - "jitter": num(rtt[1]) if len(rtt) > 1 else None, - "floor": num(m.get("minrtt")), - "sent": num(m.get("bytes_sent")) or 0.0, - "recv": num(m.get("bytes_received")) or 0.0, - "retrans_bytes": num(m.get("bytes_retrans")) or 0.0, - "delivery": num(m.get("delivery_rate")), - "cwnd": num(m.get("cwnd")), - "mss": num(m.get("mss")), - "lastsnd": num(m.get("lastsnd")), - "lastrcv": num(m.get("lastrcv")), - "raw": m, - }) - head = None - return found - - -def who(): - """Who is logged in from where, to put a name against an address.""" - seen = {} - for line in run(["who"]).splitlines(): - cols = line.split() - if len(cols) < 2: - continue - host = cols[-1].strip("()") if cols[-1].startswith("(") else "" - if host: - seen.setdefault(host, []).append((cols[0], cols[1])) - return seen - - -def rate(n): - if n is None: - return "--" - for unit, size in (("Gbps", 1e9), ("Mbps", 1e6), ("kbps", 1e3)): - if n >= size: - return "%.1f%s" % (n / size, unit) - return "%dbps" % n - - -def size_of(n): - n = float(n or 0) - for unit, step in (("G", 1e9), ("M", 1e6), ("k", 1e3)): - if n >= step: - return "%.1f%s" % (n / step, unit) - return "%dB" % n - - -def span(ms): - if ms is None: - return "--" - s = ms / 1000.0 - if s < 60: - return "%ds" % s - if s < 3600: - return "%dm" % (s // 60) - if s < 86400: - return "%dh" % (s // 3600) - return "%dd" % (s // 86400) - - -def sparkline(values, n): - """RTT over time, one cell per sample, newest at the right.""" - if not values: - return "" - tail = list(values)[-n:] - hi = max(tail) or 1.0 - return "".join(SPARK[min(7, int(v / hi * 7.99))] for v in tail) - - -class Store(object): - def __init__(self): - self.lock = threading.Lock() - self.rows = [] - self.names = {} - self.error = None - self.fetched = 0 - self.wake = threading.Event() - self.history = collections.defaultdict( - lambda: collections.deque(maxlen=HISTORY)) - self.last = {} - - def snapshot(self): - with self.lock: - return (list(self.rows), dict(self.names), - {k: list(v) for k, v in self.history.items()}, - self.fetched, self.error) - - def run(self): - # A daemon thread that raises just stops, and a dead poller looks - # exactly like a machine with nobody connected to it. - try: - self.poll() - except Exception as e: - with self.lock: - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:70]) - - def poll(self): - while True: - rows = sessions() - names = who() - for row in rows: - key = row["peer"] - if row["rtt"] is not None: - self.history[key].append(row["rtt"]) - # Retransmits since the last look, rather than since the - # connection opened: a session hours old has long since - # forgiven whatever went wrong at breakfast. - prev = self.last.get(key) - if prev: - d_sent = row["sent"] - prev["sent"] - d_retrans = row["retrans_bytes"] - prev["retrans_bytes"] - row["recent_loss"] = (100.0 * d_retrans / d_sent - if d_sent > 0 else 0.0) - row["moved"] = d_sent + (row["recv"] - prev["recv"]) - self.last[key] = dict(row) - row["spark"] = sparkline(self.history[key], 0) or "" - with self.lock: - self.rows = rows - self.names = names - self.fetched = time.time() - self.error = None if rows else self.error - self.wake.wait(REFRESH) - self.wake.clear() - - -def quality(row): - """How much worse than this path's best the connection is right now. - - Compared against the socket's own minrtt rather than a fixed threshold: - forty milliseconds is excellent from Hong Kong and poor from the next - rack, and the kernel already knows which this is. - """ - rtt, floor = row.get("rtt"), row.get("floor") - if not rtt or not floor: - return None - return rtt / floor - - -def colour_for(ratio, loss): - if loss is not None and loss >= 2.0: - return BAD - if ratio is None: - return DIM - if ratio >= 3.0 or (loss or 0) >= 0.5: - return BAD - if ratio >= 1.6: - return WARN - return OK - - -SERIES = "●▲■◆✚✦" # one glyph per session, so the plot reads mono - - -def window_label(seconds): - """A span as a person says it: 90s, 5m, 1h.""" - if seconds < 60: - return "%ds" % seconds - if seconds < 3600: - return "%gm" % round(seconds / 60.0, 1) - return "%gh" % round(seconds / 3600.0, 1) - - -def condense(vals, columns): - """Fit a run of samples to the columns available, by median. - - A fifteen-minute window at a two-second poll is 450 readings and a pane - is eighty columns wide, so something has to give. The median of each - bucket is the typical round-trip over that slice, which is what a line - should show; the worst of it is still in the table and the detail view, - which report the peak rather than the middle. - """ - if len(vals) <= columns or columns < 1: - return vals - out = [] - for i in range(columns): - chunk = vals[int(i * len(vals) / columns): - int((i + 1) * len(vals) / columns)] - if chunk: - ordered = sorted(chunk) - out.append(ordered[len(ordered) // 2]) - return out - - -def in_window(history, peer, window, columns): - """One session's samples, cut to the chosen span and to the pane.""" - vals = list(history.get(peer) or []) - if window: - vals = vals[-max(1, int(round(window / REFRESH))):] - else: - vals = vals[-columns:] - return condense(vals, columns) - - -def plotted_span(rows, history, window, columns): - """How much time the drawn columns actually cover, for the axis label.""" - longest = max([len(history.get(r["peer"]) or []) for r in rows] or [0]) - if window: - longest = min(longest, int(round(window / REFRESH))) - return longest * REFRESH - - -def build_graph(rows, history, gw, gh, start=0, window=0): - """Log-scale multi-series plot of round-trip time. - - Log because the sessions on one machine can differ by two orders of - magnitude - a laptop on the same continent and a phone on the other side - of it - and a linear axis renders the near one as a flat line at the - bottom. One column per sample rather than per second: this widget's - samples are whatever the kernel had at each poll, and pretending to a - finer time grid would be inventing resolution. - """ - lo = hi = None - series = [] - for i, row in enumerate(rows): - vals = in_window(history, row["peer"], window, gw) - if not vals: - continue - # `start` keeps a session's glyph and hue the same on its own screen - # as in the list: opening the ▲ row and finding a ● chart reads as a - # different connection. - series.append((start + i, vals)) - lo = min(vals) if lo is None else min(lo, min(vals)) - hi = max(vals) if hi is None else max(hi, max(vals)) - if lo is None: - return [seg([(DIM, " collecting…")], gw)], None, None - lo = max(0.05, lo * 0.8) - hi = max(hi * 1.25, lo * 1.6) - import math - llo, lhi = math.log10(lo), math.log10(hi) - - grid = [[" "] * gw for _ in range(gh)] - tone = [[None] * gw for _ in range(gh)] - - def row_of(v): - frac = (math.log10(max(v, 1e-3)) - llo) / (lhi - llo) - return int(round((1.0 - frac) * (gh - 1))) - - for idx, vals in series: - glyph = SERIES[idx % len(SERIES)] - colour = SESSION_HUES[idx % len(SESSION_HUES)] - start = gw - len(vals) - prev = None - for x, v in enumerate(vals): - y = row_of(v) - col = start + x - if prev is not None and abs(prev - y) > 1: - # join consecutive samples so a series reads as a trace - for fill in range(min(prev, y) + 1, max(prev, y)): - if grid[fill][col] == " ": - grid[fill][col] = "│" - tone[fill][col] = colour - if 0 <= col < gw and 0 <= y < gh: - grid[y][col] = glyph - tone[y][col] = colour - prev = y - return grid, tone, (lo, hi) - - -def graph_rows(rows, history, w, h, start=0, window=0): - gw = max(10, w - 9) - gh = max(4, h) - grid, tone, bounds = build_graph(rows, history, gw, gh, start, window) - if bounds is None: - return grid - import math - lo, hi = bounds - llo, lhi = math.log10(lo), math.log10(hi) - out = [] - for y, line in enumerate(grid): - frac = 1.0 - (y / float(max(1, gh - 1))) - value = 10 ** (llo + frac * (lhi - llo)) - # label only the top, middle and bottom: a number on every row is a - # table pretending to be an axis - label = ("%7s" % ms(value)) if y in (0, gh // 2, gh - 1) else " " * 7 - parts = [(DIM, label), (GRID, "│")] - for x, ch in enumerate(line): - parts.append((tone[y][x] or GRID, ch)) - out.append(seg(parts, w - 1)) - return out - - -def ms(v): - if v >= 100: - return "%dms" % round(v) - if v >= 10: - return "%.0fms" % v - if v >= 1: - return "%.1fms" % v - return "%dµs" % round(v * 1000) - - -def table_rows(rows, names, history, w, selected): - """One line per session: what it is, and how it is behaving.""" - wide = w >= 74 - out = [seg([(DIM, " PEER"), (DIM, " " * 14), - (DIM, " NOW FLOOR JITTER LOSS"), - (DIM, " ACHIEVED" if wide else ""), - (DIM, " IDLE" if wide else "")], w - 1)] - for i, row in enumerate(rows): - here = i == selected - tint = bg(28, 44, 62) if here else "" - ratio = quality(row) - loss = row.get("recent_loss") - tone = colour_for(ratio, loss) - users = names.get(row["ip"]) or [] - who_txt = users[0][0] if users else "" - idle = min([x for x in (row.get("lastsnd"), row.get("lastrcv")) - if x is not None] or [None]) - label = row["ip"] + (" %s" % who_txt if who_txt and wide else "") - line = [(tint + SESSION_HUES[i % len(SESSION_HUES)], - SERIES[i % len(SERIES)] + " "), - (tint + (TXT if here else DIM), pad(label, 18)), - (tint + tone, "%7s" % ms(row["rtt"]) if row["rtt"] else " --"), - (tint + DIM, "%8s" % ms(row["floor"]) if row["floor"] else " --"), - (tint + DIM, "%8s" % ms(row["jitter"]) if row["jitter"] else " --"), - (tint + (BAD if (loss or 0) >= 0.5 else DIM), - "%7s" % ("%.2f%%" % loss if loss is not None else "--"))] - if wide: - line.append((tint + DIM, "%10s" % rate(row.get("delivery")))) - line.append((tint + DIM, "%7s" % span(idle))) - if here: - line.append((tint, " " * w)) - out.append(seg(line, w - 1)) - return out - - -def fit(w, base, options): - """Build a line from a required head plus optional parts, longest first. - - Each option is (short, long): the long form is taken when the whole line - still fits, otherwise the short one, otherwise nothing. Width thresholds - were doing this by eye and getting it wrong - the numbers on this line - vary in length, so a threshold tuned at one pane size clipped "idle 0s" - into "idle 0" at another. - """ - parts = list(base) - - def width(extra): - return sum(len(t) for t, _c in parts + extra) - - for i, (short, long) in enumerate(options): - # Whatever is still to come gets its shortest form reserved before - # this one is allowed to take its longest. Without that, a middle - # option spent the width on "segments" and pushed the idle time off - # the end - dropping a fact to spell out a unit. - rest = sum(len(t) for nxt, _l in options[i + 1:] for t, _c in (nxt or [])) - for candidate in (long, short): - if candidate and width(candidate) + rest <= w - 1: - parts.extend(candidate) - break - return seg([(colour, text) for text, colour in parts], w - 1) - - -def detail_rows(row, names, w, selected=False, hue=None, glyph="●"): - """The selected session in full: who, how much, and how it is going. - - The table above answers "is anything wrong"; this answers "with what". - Lifetime loss lives here rather than in the table because it is a fact - about the whole session and changes by the hour, while the table's loss - column is about the last two seconds. - """ - # `who` maps logins to an address, not to a socket, and two SSH sessions - # from one laptop share the address. Naming both against each socket read - # as "this connection is pts/0 and pts/35", which it is not - so the - # ttys are labelled as what they are: the logins from that address. - users = names.get(row["ip"]) or [] - label = "" - if users: - label = "%s · login%s %s" % ( - users[0][0], "" if len(users) == 1 else "s", - ", ".join(t for _u, t in users[:3])) - lifetime = (100.0 * row["retrans_bytes"] / row["sent"] - if row["sent"] else 0.0) - idle = min([x for x in (row.get("lastsnd"), row.get("lastrcv")) - if x is not None] or [None]) - tint = bg(28, 44, 62) if selected else "" - head = fit(w, [(" " + glyph + " ", tint + (hue or LBL)), - (row["ip"], tint + TXT)], - [([(" · port %d" % row["port"], tint + DIM)], None), - ([(" " + users[0][0], tint + DIM)] if users else [], - [(" " + label, tint + DIM)] if label else [])]) - return [head, - seg([(DIM, " sent "), (TXT, size_of(row["sent"])), - (DIM, " · received "), (TXT, size_of(row["recv"])), - (DIM, " · achieved "), (TXT, rate(row.get("delivery")))], - w - 1), - # Built shortest-first and grown while it fits, rather than - # trimmed by width thresholds: the numbers vary in length, so a - # threshold that held at one window size cut "idle 0s" to - # "idle 0" at another. - fit(w, [(" retransmitted ", DIM), ("%.2f%%" % lifetime, TXT)], - [([(" lifetime", DIM)], [(" over the session", DIM)]), - ([(" · flight ", DIM), - ("%.0f" % row["cwnd"] if row["cwnd"] else "--", TXT)], - [(" · up to ", DIM), - ("%.0f" % row["cwnd"] if row["cwnd"] else "--", TXT), - (" packets in flight", DIM)]), - ([(" · idle ", DIM), (span(idle), TXT)], None)])] - - -def detail_view(row, names, history, w, h, idx=0, window=0): - """One connection, in full. - - The list answers "is anything wrong"; this answers "with what, and how - badly". Everything here is a number the kernel already keeps for this - socket - nothing is derived beyond the two percentages, and both say - what they are measured over. - """ - raw = row.get("raw") or {} - users = names.get(row["ip"]) or [] - rows = [title("connection", w, LINK)] - rows.append(seg([(SESSION_HUES[idx % len(SESSION_HUES)], - " " + SERIES[idx % len(SERIES)] + " "), - (TXT, row["ip"]), - (DIM, " · port %d" % row["port"]), - (DIM, (" " + users[0][0]) if users else "")], w - 1)) - if users: - rows.append(seg([(DIM, " logins from this address: "), - (TXT, ", ".join(t for _u, t in users))], w - 1)) - rows.append("") - - def field(label, value, colour=TXT, note=""): - if value in (None, "", "--"): - return - rows.append(seg([(DIM, " %-16s" % label), (colour, str(value)), - (DIM, " " + note if note else "")], w - 1)) - - ratio = quality(row) - field("round trip", ms(row["rtt"]) if row["rtt"] else None, - colour_for(ratio, row.get("recent_loss")), - "%.1fx this path's best" % ratio if ratio else "") - field("best ever", ms(row["floor"]) if row["floor"] else None, DIM, - "the floor; the gap above it is congestion") - field("jitter", ms(row["jitter"]) if row["jitter"] else None, DIM, - "variation in the round trip") - field("timeout", ms(num(raw.get("rto"))) if raw.get("rto") else None, DIM, - "how long before a lost packet is resent") - rows.append("") - - lifetime = (100.0 * row["retrans_bytes"] / row["sent"] - if row["sent"] else 0.0) - loss = row.get("recent_loss") - field("loss just now", "%.2f%%" % loss if loss is not None else None, - BAD if (loss or 0) >= 0.5 else TXT, "resent since the last look") - field("loss lifetime", "%.2f%%" % lifetime, DIM, - "%s resent of %s" % (size_of(row["retrans_bytes"]), - size_of(row["sent"]))) - field("reordering", raw.get("reord_seen"), DIM, - "times packets arrived out of order") - rows.append("") - - field("sent", size_of(row["sent"]), TXT) - field("received", size_of(row["recv"]), TXT) - field("achieved", rate(row.get("delivery")), TXT, - "what it has delivered, not its capacity") - field("pacing at", rate(num(raw.get("pacing_rate"))), DIM, - "the rate the kernel is willing to send at") - field("in flight", raw.get("cwnd"), DIM, - "packets allowed unacknowledged at once") - field("packet size", "%s bytes" % raw["mss"] if raw.get("mss") else None, - DIM) - idle = min([x for x in (row.get("lastsnd"), row.get("lastrcv")) - if x is not None] or [None]) - field("idle", span(idle), DIM, "since anything crossed either way") - rows.append("") - - room = h - len(rows) - 4 - if room >= 5: - rows.extend(graph_rows([row], history, w, room, idx, window)) - rows.append(seg([(DIM, " " * 7), - (GRID, "└" + "─" * max(10, w - 9))], w - 1)) - oldest = plotted_span([row], history, window, max(10, w - 9)) - rows.append(seg([(DIM, " %s ago" % span(oldest * 1000)), - (DIM, " " * max(1, w - 26)), (DIM, "now")], w - 1)) - return rows - - -def main(): - maybe_help(__doc__) - global REFRESH - args = sys.argv[1:] - while args and args[0] in ("-n", "--refresh"): - REFRESH = max(0.5, float(args[1])) - args = args[2:] - - if not run(["ss", "-V"]): - cannot_start( - "connections", ["ss"], - ["ss reads the kernel's own per-socket metrics, which is where", - "every figure here comes from: round-trip time, retransmits,", - "delivery rate. Nothing else on the machine reports them.", - "", - "It ships in iproute2, which is installed on essentially every", - "Linux system - its absence usually means a very small container", - "image rather than a missing package."], - "apt install iproute2") - - setup() - keyboard = Keyboard() - store = Store() - threading.Thread(target=store.run, daemon=True).start() - selected, hide_idle, tick, view = 0, False, 0, None - span_at = 0 # which of WINDOWS the chart is currently drawn over - - while True: - tick += 1 - for key in keyboard.poll(): - if key in ("q", "Q"): - raise SystemExit(0) - if key in ("up", "k"): - selected -= 1 - elif key in ("down", "j"): - selected += 1 - elif key in ("enter", "i"): - view = None if view else "detail" - elif key == "esc": - view = None - elif key == "o": - hide_idle = not hide_idle - elif key in ("w", "W"): - span_at = (span_at + 1) % len(WINDOWS) - elif key == "r": - store.wake.set() - - w, h = size() - rows_all, names, history, fetched, err = store.snapshot() - shown = [r for r in rows_all - if not (hide_idle and (r.get("lastrcv") or 0) > IDLE_AFTER * 1000)] - selected = max(0, min(selected, len(shown) - 1)) if shown else 0 - - # One connection in full, on its own screen. The list is for - # noticing; this is for looking into, and the two want different - # amounts of room for the same chart. - window = WINDOWS[span_at] - if view and shown: - pick = min(selected, len(shown) - 1) - # The footer is measured before the body is built, and the body - # is told the height it actually has. Appending the hints after - # sizing the chart to the whole pane pushed them off the bottom - # of it - the keys out of this screen were the rows being lost. - foot = [" " + line for line in - pack_hints([[(DIM, "[esc] back")], - [(ACCENT, "[w]"), - (DIM, " %s" % window_label(window))], - [(DIM, "[r]efresh")], - [(DIM, "[q]uit")]], w - 2)] - room = max(1, h - len(foot) - 1) - body = detail_view(shown[pick], names, history, w, room, pick, - window) - while len(body) < room: - body.append("") - draw(body[:room] + foot, w, h) - time.sleep(0.2) - continue - - rows = [title("connections", w, LINK)] - rows.append(seg([(DIM, " %d inbound" % len(rows_all)), - (DIM, " · measured by the kernel, nothing sent"), - (DIM, " every %gs" % REFRESH)], w - 1)) - if err: - rows.append(seg([(BAD, " ! " + err)], w - 1)) - rows.append("") - - if not rows_all: - rows.append(seg([(DIM, " No inbound sessions on a listening" - " port.")], w - 1)) - rows.append(seg([(DIM, " Nothing is connected to this machine," - " or `ss` cannot see it.")], w - 1)) - else: - rows.extend(table_rows(shown, names, history, w, selected)) - rows.append("") - room = h - len(rows) - 4 - if room >= 5: - rows.extend(graph_rows(shown, history, w, room, 0, window)) - rows.append(seg([(DIM, " " * 7), - (GRID, "└" + "─" * max(10, w - 9))], w - 1)) - oldest = plotted_span(shown, history, window, - max(10, w - 9)) - rows.append(seg([(DIM, " %s ago" % span(oldest * 1000)), - (DIM, " " * max(1, w - 26)), - (DIM, "now")], w - 1)) - - while len(rows) < h - 2: - rows.append("") - hints = [[(ACCENT, "↑↓"), (DIM, " select")], - [(DIM, "[↵] open")], - [(ACCENT, "[w]"), (DIM, " %s" % window_label(window))], - [(DIM, "[o]%s idle" % ("show" if hide_idle else "hide"))], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]] - for line in pack_hints(hints, w - 2): - rows.append(" " + line) - draw(rows, w, h) - time.sleep(0.3) - - -main() diff --git a/matrix.py b/matrix.py deleted file mode 100755 index 13a85be..0000000 --- a/matrix.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Digital rain. - -Falling glyphs with truecolor fade trails: near-white head, bright green -shoulder, and a smooth decay over each drop's length. Glyphs mutate in place -independently of the drops, and the field reflows on terminal resize. - - python3 matrix.py -""" -import os -import random -import sys -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import RST, draw, maybe_help, rgb, setup, size - -GLYPHS = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789ABCDEF<>*+=$#%&@" - -HEAD = rgb(210, 255, 225) -NEAR = rgb(120, 255, 170) - - -def shade(level): - # level 0..1 (1 = closest to head) - g = int(60 + 175 * level) - return rgb(int(10 + 20 * level), g, int(30 + 50 * level)) - - -def new_drop(h): - return [-random.uniform(0, h * 1.5), random.uniform(0.25, 1.15), - random.randint(max(4, h // 5), max(6, h))] - - -def main(): - maybe_help(__doc__) - setup() - w, h = size() - chars = [[random.choice(GLYPHS) for _ in range(w)] for _ in range(h)] - drops = [new_drop(h) for _ in range(w)] - while True: - nw, nh = size() - if (nw, nh) != (w, h): - w, h = nw, nh - chars = [[random.choice(GLYPHS) for _ in range(w)] for _ in range(h)] - drops = [new_drop(h) for _ in range(w)] - level = [[0.0] * w for _ in range(h)] - for x, d in enumerate(drops): - y, speed, ln = d - d[0] = y + speed - if y - ln > h: - drops[x] = new_drop(h) - continue - hy = int(y) - for i in range(ln): - yy = hy - i - if 0 <= yy < h: - level[yy][x] = max(level[yy][x], 1.0 - i / float(ln)) - for _ in range(max(6, (w * h) // 90)): - chars[random.randrange(h)][random.randrange(w)] = random.choice(GLYPHS) - - rows = [] - for y in range(h): - parts = [] - last = None - lv = level[y] - cs = chars[y] - for x in range(w): - v = lv[x] - if v <= 0.02: - if last is not None: - parts.append(RST) - last = None - parts.append(" ") - continue - col = HEAD if v > 0.985 else (NEAR if v > 0.9 else shade(v)) - if col != last: - parts.append(col) - last = col - parts.append(cs[x]) - rows.append("".join(parts)) - draw(rows, w, h) - time.sleep(0.07) - - -main() diff --git a/netwatch.py b/netwatch.py deleted file mode 100755 index 6e7e2d7..0000000 --- a/netwatch.py +++ /dev/null @@ -1,1427 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Which processes are using the network, how much, and how fast. - -`nettop` answers this on macOS and has no equivalent here. What Linux does -have is the kernel's own per-socket accounting: `ss -tine` reports bytes_sent -and bytes_received for every TCP socket along with its inode, and the inode -appears in /proc//fd, which is what ties bytes to a process. No packet -capture, no kernel module, no root. - - python3 netwatch.py [-i SECONDS] [-n COUNT] [--sort total|live] - [--external] [--plain] - -Only traffic that leaves the machine is counted. Loopback is excluded, and so -is any connection to one of this machine's own addresses - talking to your own -10.x or tailnet address never reaches a wire, however external it looks in the -socket table. --external is the narrower question of internet-only, and drops -the local network and the tailnet too. - -Totals start at zero: the first sample is a baseline and only what happens -after it is counted. A process that exits keeps what it used, marked so, since -"what has been eating the connection" is usually asked after the thing has -stopped. - -TCP only, which is the honest limit of this method - see docs/netwatch.md. - -Enter opens one process: its command, every connection it holds separately, -and the files it currently has open with how fast each is growing - which is -the closest thing to "which file is it downloading" that exists outside the -encrypted stream. The URL and the remote filename are inside TLS and are not -readable from here by any means. - -Keys: up/down select, enter opens one, esc goes back, 1 sorts by total, -2 by current rate, o shows the daemons you do not own, r rezeroes, -q quits. -""" -import collections -import json -import os -import re -import socket -import subprocess -import sys -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (Keyboard, bg, cannot_start, clipboard, draw, load_config, - maybe_help, missing, pack_hints, pad, rgb, seg, setup, - size, title) - -_CFG = load_config("netwatch", { - "interval": 1.0, - "limit": 0, # 0 fills the pane - "sort": "total", - "external": True, - # Only processes you own. Everything else is the machine's own daemons - - # tailscaled, a cloud guest agent, sshd - which are traffic you did not - # ask for and cannot do anything about. - "mine": True, -}) - -INTERVAL = max(0.2, float(_CFG["interval"])) -# Samples kept for the chart. Wider than any pane, so the graph is a window -# on to real history rather than exactly as much as happened to fit. -SERIES = 240 -LIMIT = int(_CFG["limit"]) -SORT = _CFG["sort"] if _CFG["sort"] in ("total", "live") else "total" -# Strict by default: a connection counts only when the other end is a -# globally routable address. The LAN, the tailnet and the machine's own -# addresses are all somewhere other than the internet, and "what is this box -# sending out" is almost always the question being asked. -EXTERNAL = bool(_CFG["external"]) -MINE = bool(_CFG["mine"]) -VERSION = "1.1" -PLAIN = False - -OK = rgb(90, 240, 160) -WARN = rgb(255, 200, 90) -BAD = rgb(255, 100, 110) -DIM = rgb(127, 147, 172) -GRID = rgb(60, 78, 98) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) -DOWN = rgb(120, 200, 255) -UP = rgb(255, 170, 120) - -INO = re.compile(r"\bino:(\d+)") -CGROUP = re.compile(r"\bcgroup:(\S+)") -SENT = re.compile(r"\bbytes_sent:(\d+)") -RECV = re.compile(r"\bbytes_received:(\d+)") -# 10/8, 172.16/12, 192.168/16, 169.254/16 and Tailscale's 100.64/10 are all -# somewhere other than the internet. -PRIVATE = re.compile(r"^(10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|" - r"100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)") -UNATTRIBUTED = "(unattributed)" -# Systemd names the slice, not the thing in it. -SLICES = ("system.slice", "user.slice", "init.scope", "-.slice", "app.slice") - - -def unit_name(cgroup): - """Who owns a socket, from the cgroup the kernel already reports. - - Another user's /proc is closed, but `ss` prints the control group for - every socket regardless, and on a systemd machine that names the unit: - /system.slice/tailscaled.service is tailscaled however unreadable its - /proc happens to be. This is the difference between a row saying - "(unattributed)" and a row saying which daemon it is. - """ - for part in reversed((cgroup or "").strip("/").split("/")): - if not part or part in SLICES: - continue - for suffix in (".service", ".scope", ".slice"): - if part.endswith(suffix): - part = part[:-len(suffix)] - break - # A login session is a person, not a program, and says nothing - # useful about what opened the socket. - if part.startswith("session-") or part.startswith("user-"): - continue - return part - return "" - - -def run(args): - try: - out = subprocess.run(args, capture_output=True, text=True, timeout=5) - except (OSError, subprocess.SubprocessError): - return "" - return out.stdout if out.returncode == 0 else "" - - -def units(n): - """Decimal units, as network equipment and ISPs quote them.""" - n = float(n) - for suffix, scale in (("GB", 1e9), ("MB", 1e6), ("KB", 1e3)): - if n >= scale: - return "%.1f %s" % (n / scale, suffix) - return "%d B" % n - - -def rate(n): - return units(n) + "/s" if n else "-" - - -def elapsed(seconds): - seconds = int(seconds) - if seconds < 60: - return "%ds" % seconds - if seconds < 3600: - return "%dm %02ds" % (seconds // 60, seconds % 60) - return "%dh %02dm" % (seconds // 3600, (seconds % 3600) // 60) - - -def host_of(addr): - """The address out of ss's `addr:port`, brackets stripped from IPv6.""" - host, _, _ = addr.rpartition(":") - return host.strip("[]") - - -_OWN = {"at": 0.0, "addrs": set()} - - -def own_addresses(): - """Every address this machine answers to, refreshed occasionally. - - A connection to one of our own addresses is turned around inside the - kernel and never reaches a wire, so it is not traffic leaving the - machine even though the address is not loopback. Interfaces come and go - - a tailnet address arrives when tailscaled starts, a bridge when a - container does - so this is re-read periodically rather than once. - """ - now = time.time() - if _OWN["addrs"] and now - _OWN["at"] < 30: - return _OWN["addrs"] - found = set() - try: - data = json.loads(run(["ip", "-j", "addr"]) or "[]") - except ValueError: - data = [] - for link in data: - for addr in link.get("addr_info") or []: - if addr.get("local"): - found.add(addr["local"]) - if found: - _OWN["addrs"] = found - _OWN["at"] = now - return _OWN["addrs"] - - -def port_of(addr): - _, _, port = addr.rpartition(":") - try: - return int(port) - except ValueError: - return 0 - - -def service(port): - """What a port number is conventionally for, from /etc/services.""" - if not port: - return "" - try: - return socket.getservbyport(port) - except (OSError, TypeError): - return "" - - -def local_peer(host): - """Whether this traffic never leaves the machine. - - Loopback is the obvious half. The other is a connection to one of this - machine's own addresses - 10.x to itself, or its own tailnet address - - which looks external in the socket table and is not: the kernel routes - it back up the stack without a packet ever reaching an interface. - """ - if (host.startswith("127.") or host in ("::1", "*", "") - or host.startswith("::ffff:127.")): - return True - bare = host[7:] if host.startswith("::ffff:") else host - return bare in own_addresses() - - -def off_box(host): - """Whether a peer is out on the internet rather than nearby.""" - if local_peer(host): - return False - if host.startswith("::ffff:"): - host = host[7:] - return not (PRIVATE.match(host) or host.startswith("fd7a:115c:a1e0") - or host.startswith("fe80:") or host.startswith("fc") - or host.startswith("fd")) - - -def sockets(external=False): - """Every TCP socket's byte counters, keyed by inode. - - -i for the counters, -e for the inode. Without the inode there is no - honest way to reach the process: `ss -p` needs root to name anybody - else's, while /proc//fd needs nothing to name our own. - """ - try: - out = subprocess.run(["ss", "-tine"], capture_output=True, text=True, - timeout=5) - except (OSError, subprocess.SubprocessError): - return {}, "ss would not run" - found = {} - inode, peer, port, cgroup, header = None, "", 0, "", True - for line in out.stdout.splitlines(): - if header: - header = False - continue - # A socket is two lines: the addresses and inode, then the counters - # on an indented continuation. Neither is usable without the other. - if not line.startswith((" ", "\t")): - cols = line.split() - peer = host_of(cols[4]) if len(cols) > 4 else "" - port = port_of(cols[4]) if len(cols) > 4 else 0 - seen = INO.search(line) - inode = seen.group(1) if seen else None - unit = CGROUP.search(line) - cgroup = unit.group(1) if unit else "" - # ino:0 is a socket with no inode to own it - a TIME-WAIT - # remnant, say. It cannot be attributed, and worse, every one of - # them shares the key, so they would be merged into a single - # entry whose counters jump about and manufacture deltas. - if inode == "0": - inode = None - continue - if inode is None: - continue - if local_peer(peer) or (external and not off_box(peer)): - inode = None - continue - sent, recv = SENT.search(line), RECV.search(line) - found[inode] = {"sent": int(sent.group(1)) if sent else 0, - "recv": int(recv.group(1)) if recv else 0, - "peer": peer, "port": port, "cgroup": cgroup} - inode = None - return found, "" - - -# Directory names that identify nothing: a binary living under one of these -# is named by whatever encloses it. -GENERIC = {"versions", "bin", "sbin", "libexec", "node_modules", "dist", - "build", "lib", "share", "local", ".local", "current", "releases"} -HAS_LETTER = re.compile(r"[A-Za-z]") - - -def process_name(pid): - """What to call a process, preferring something a person would recognise. - - /proc//comm is the kernel's answer and usually right, but it is the - executable's own name, and some are versioned - a binary at - .../claude/versions/2.1.233 reports itself as "2.1.233", which is true - and useless. When the name carries no letters at all, the enclosing path - is walked back for one that does and means something. - """ - try: - with open("/proc/%d/comm" % pid) as f: - name = f.read().strip() - except OSError: - name = "" - if name and HAS_LETTER.search(name): - return name - try: - with open("/proc/%d/cmdline" % pid, "rb") as f: - argv0 = f.read().split(b"\x00")[0].decode("utf8", "replace") - except OSError: - argv0 = "" - for part in reversed(argv0.split("/")): - if part and HAS_LETTER.search(part) and part.lower() not in GENERIC: - return part - return name or "?" - - -# Interfaces that are not the wire: loopback, and anything that is a tunnel -# or a bridge rather than a card. A packet forwarded out of one of these -# leaves through a real interface as well, and counting both would count it -# twice. -VIRTUAL = ("lo", "tailscale0", "docker", "veth", "br-", "virbr", "wg", - "tun", "tap", "cni", "flannel", "kube") - - -def wire_bytes(): - """Bytes in and out of this machine's real interfaces. - - The kernel counts these whatever produced them, which is the point: a - packet this machine routes rather than terminates never touches a - socket, so /proc/net/tcp cannot see it and neither can anything built on - it. On an exit node or a subnet router that is most of the traffic. - """ - rx, tx, names = 0, 0, [] - try: - lines = open("/proc/net/dev").read().splitlines()[2:] - except OSError: - return None - for line in lines: - name, _, rest = line.partition(":") - name = name.strip() - if not rest or name.startswith(VIRTUAL): - continue - fields = rest.split() - if len(fields) < 9: - continue - try: - rx += int(fields[0]) - tx += int(fields[8]) - except ValueError: - continue - names.append(name) - return rx, tx, names - - -def wire_label(names): - """Which interfaces are being counted, for the end of the line. - - The line is called "interfaces" because that is what it is. Naming them - as well answers "which?" - a real question here, tailscale0 being - deliberately absent - and is dropped first when the pane is narrow. - """ - if not names: - return "" - if len(names) <= 3: - return ", ".join(names) - return "%d of them" % len(names) - - -def socket_owners(): - """inode -> (pid, name), for every process this user can read. - - Another user's /proc//fd is unreadable, so their sockets arrive - unowned. Their bytes are still counted, under one row that says so: - dropping them would make the total quietly wrong. - """ - owners = {} - for pid in os.listdir("/proc"): - if not pid.isdigit(): - continue - try: - fds = os.listdir("/proc/%s/fd" % pid) - except OSError: - continue - name = "" - for fd in fds: - try: - target = os.readlink("/proc/%s/fd/%s" % (pid, fd)) - except OSError: - continue - if not target.startswith("socket:["): - continue - if not name: - name = process_name(int(pid)) - owners[target[8:-1]] = (int(pid), name or "?") - return owners - - -_NAMES = {} -_WANTED = collections.deque() -_ASKED = set() - - -def resolver(): - """Reverse DNS, off the drawing thread. - - A PTR lookup takes half a second when it works and longer when it does - not, which is several frames. The address is shown until a name arrives, - and an address that has no name is remembered as having none so it is - not asked about again every second. - """ - while True: - try: - ip = _WANTED.popleft() - except IndexError: - time.sleep(0.3) - continue - try: - _NAMES[ip] = socket.gethostbyaddr(ip)[0] - except (OSError, socket.herror, socket.gaierror): - _NAMES[ip] = "" - - -def hostname(ip): - """A name for an address if one is known, queueing a lookup if not.""" - if ip in _NAMES: - return _NAMES[ip] - if ip not in _ASKED: - _ASKED.add(ip) - _WANTED.append(ip) - return "" - - -def running(pid): - """Whether the process still exists. - - Distinct from the `alive` flag on a row, which means "had a socket in - the last sample". A long-running server sitting idle has neither - traffic nor open connections and has certainly not exited, and saying - it had would be worse than saying nothing. - """ - return bool(pid) and os.path.isdir("/proc/%d" % pid) - - -def proc_io(pid): - """Disk bytes this process has read and written, from /proc//io.""" - out = {} - try: - with open("/proc/%d/io" % pid) as f: - for line in f: - key, _, value = line.partition(":") - try: - out[key.strip()] = int(value) - except ValueError: - continue - except OSError: - return {} - return out - - -def open_files(pid): - """Regular files this process has open, largest first. - - A download has to land somewhere, and where it lands is a file getting - bigger. This is the closest thing to "which file" that exists outside - the encrypted stream - the name of the thing being written, rather than - the name of the thing being fetched. - """ - found = [] - try: - fds = os.listdir("/proc/%d/fd" % pid) - except OSError: - return found - for fd in fds: - try: - path = os.readlink("/proc/%d/fd/%s" % (pid, fd)) - except OSError: - continue - if not path.startswith("/") or path.startswith(("/dev/", "/proc/", - "/sys/")): - continue - try: - size = os.stat("/proc/%d/fd/%s" % (pid, fd)).st_size - except OSError: - continue - writing = False - try: - with open("/proc/%d/fdinfo/%s" % (pid, fd)) as f: - for line in f: - if line.startswith("flags:"): - writing = int(line.split()[1], 8) & 3 != 0 - except (OSError, ValueError, IndexError): - pass - found.append({"path": path, "size": size, "writing": writing}) - found.sort(key=lambda f: -f["size"]) - return found - - -def process_facts(pid): - """Command, directory and age - what the table has no room for.""" - facts = {"cmdline": "", "cwd": "", "started": None} - try: - with open("/proc/%d/cmdline" % pid, "rb") as f: - facts["cmdline"] = f.read().replace(b"\x00", b" ").decode( - "utf8", "replace").strip() - except OSError: - pass - try: - facts["cwd"] = os.readlink("/proc/%d/cwd" % pid) - except OSError: - pass - try: - facts["started"] = os.stat("/proc/%d" % pid).st_ctime - except OSError: - pass - return facts - - -class Store(object): - """Per-process byte totals, accumulated from per-socket counters. - - The kernel counts per socket, not per process, and a socket's counters - vanish with it. So each sample takes the difference against what that - socket last read and adds it to whatever process owns it - which keeps - the total intact when the socket closes, and when the process does. - """ - - def __init__(self): - self.lock = threading.Lock() - self.wake = threading.Event() - self.totals = collections.OrderedDict() - self.conns = collections.OrderedDict() - self.series = collections.deque(maxlen=SERIES) - self.spots = collections.OrderedDict() - self.last = {} - self.started = time.time() - self.stamp = 0.0 - self.wire = None # last (rx, tx) off the interfaces - self.wire_rate = (0.0, 0.0) - self.wire_names = [] - self.err = "" - self.rezero = False - - def snapshot(self): - with self.lock: - return ([dict(v, key=k) for k, v in self.totals.items()], - self.started, self.err) - - def wire_now(self): - """The interfaces' current rate, in and out, and what they are.""" - with self.lock: - return self.wire_rate + (list(self.wire_names),) - - def rates(self, mine=True): - """Total down and up rate per sample, yours or the whole machine's.""" - with self.lock: - window = list(self.series) - return [(s[0], s[1]) if mine else (s[2], s[3]) for s in window] - - def endpoints(self, pid, name): - """One process's remote hosts, aggregated, busiest first.""" - with self.lock: - found = [dict(s, ports=sorted(s["ports"]), - hist=list(s["hist"])) - for s in self.spots.values() - if s["pid"] == pid and s["name"] == name] - return sorted(found, key=lambda s: (-(s["up"] + s["down"]), - s["peer"])) - - def history(self, pid, name): - """One process's rate history, for its own chart.""" - with self.lock: - row = self.totals.get((pid, name)) - return list(row["hist"]) if row else [] - - def connections(self, pid, name): - """One process's individual connections, busiest first.""" - with self.lock: - found = [dict(c) for c in self.conns.values() - if c["pid"] == pid and c["name"] == name] - return sorted(found, key=lambda c: (-(c["up"] + c["down"]), - c["peer"])) - - def reset(self): - """Make the current counters the new zero. - - Nothing from before the reset may reappear, so the last-seen map is - kept and only the accumulated totals go: the next sample then - differences against counters read before the reset and adds nothing - for traffic that predates it. - """ - with self.lock: - self.totals.clear() - self.conns.clear() - self.spots.clear() - self.series.clear() - self.started = time.time() - - def run(self): - # A daemon thread that raises just stops, and a dead sampler looks - # exactly like a machine using no network at all. - try: - self.poll() - except Exception as exc: - with self.lock: - self.err = "sampler stopped: %s: %s" % (type(exc).__name__, - str(exc)[:70]) - - def poll(self): - while True: - now = time.time() - counters = wire_bytes() - found, err = sockets(EXTERNAL) - owners = socket_owners() if found else {} - gap = max(1e-6, now - self.stamp) if self.stamp else 0.0 - with self.lock: - self.err = err - for row in self.totals.values(): - row["up_rate"] = row["down_rate"] = 0.0 - row["alive"] = False - for conn in self.conns.values(): - conn["up_rate"] = conn["down_rate"] = 0.0 - conn["alive"] = False - for spot in self.spots.values(): - spot["up_rate"] = spot["down_rate"] = 0.0 - spot["alive"] = False - first = not self.stamp - for inode, seen in found.items(): - sent, recv = seen["sent"], seen["recv"] - was = self.last.get(inode) - was = was if was is None else (was["sent"], was["recv"]) - # A socket opened since the last sample started at zero - # when it was created, so all of its counters are traffic - # that happened while we were watching. Only the sockets - # already open at the very first sample are zeroed - the - # difference is a connection that opens and closes inside - # one interval, whose bytes would otherwise never be - # counted at all. - if first: - d_sent = d_recv = 0 - elif was is None: - d_sent, d_recv = sent, recv - # A reused inode reads lower than it did. Taking the - # difference would underflow, so the new socket's own - # figures are used instead. - elif sent < was[0] or recv < was[1]: - d_sent, d_recv = sent, recv - else: - d_sent, d_recv = sent - was[0], recv - was[1] - pid, name = owners.get(inode, (0, "")) - if not name: - name = unit_name(seen.get("cgroup")) or UNATTRIBUTED - key = (pid, name) - row = self.totals.get(key) - if row is None: - row = {"pid": pid, "name": name, "up": 0, "down": 0, - "up_rate": 0.0, "down_rate": 0.0, - "alive": True, "seen": now, - "hist": collections.deque(maxlen=SERIES)} - self.totals[key] = row - row["alive"] = True - row["seen"] = now - row["up"] += d_sent - row["down"] += d_recv - if gap: - row["up_rate"] += d_sent / gap - row["down_rate"] += d_recv / gap - - # The same arithmetic per connection, so the detail - # screen can say which of a process's dozen sockets is - # the one actually moving. - conn = self.conns.get(inode) - if conn is None: - conn = {"pid": pid, "name": name, "peer": seen["peer"], - "port": seen["port"], "up": 0, "down": 0, - "up_rate": 0.0, "down_rate": 0.0, - "alive": True, "seen": now, - "opened": 0 if first else now} - self.conns[inode] = conn - conn["alive"] = True - conn["seen"] = now - conn["up"] += d_sent - conn["down"] += d_recv - if gap: - conn["up_rate"] += d_sent / gap - conn["down_rate"] += d_recv / gap - - # Endpoints aggregate the sockets sharing a peer: a - # browser opening six connections to one host is one - # thing being talked to, not six. - spot = self.spots.setdefault( - (pid, name, seen["peer"]), - {"pid": pid, "name": name, "peer": seen["peer"], - "up": 0, "down": 0, "up_rate": 0.0, - "down_rate": 0.0, "alive": True, "seen": now, - "ports": set(), - "hist": collections.deque(maxlen=SERIES)}) - spot["alive"] = True - spot["seen"] = now - spot["ports"].add(seen["port"]) - spot["up"] += d_sent - spot["down"] += d_recv - if gap: - spot["up_rate"] += d_sent / gap - spot["down_rate"] += d_recv / gap - # What the interfaces actually moved, against what the - # sockets can explain. On a router the two differ by most of - # the traffic, and a widget that only showed the second - # would be quietly answering a different question. - if counters and self.wire and gap: - self.wire_rate = (max(0, counters[0] - self.wire[0]) / gap, - max(0, counters[1] - self.wire[1]) / gap) - if counters: - self.wire = counters[:2] - self.wire_names = counters[2] - - # The whole machine's rate this sample, for the chart. - # Summed from the same per-process figures the table shows, - # so the two can never disagree. - if gap: - self.series.append(( - sum(r["down_rate"] for r in self.totals.values() - if r["pid"]), - sum(r["up_rate"] for r in self.totals.values() - if r["pid"]), - sum(r["down_rate"] for r in self.totals.values()), - sum(r["up_rate"] for r in self.totals.values()))) - for row in self.totals.values(): - row["hist"].append((row["down_rate"], row["up_rate"])) - for spot in self.spots.values(): - spot["hist"].append((spot["down_rate"], - spot["up_rate"])) - self.last = dict(found) - self.stamp = now - # A closed connection is worth keeping - it may be the one - # that did the damage - but not forever. The quiet dead ones - # go once there are enough of them to matter. - if len(self.conns) > 400: - for inode, conn in sorted( - self.conns.items(), - key=lambda kv: kv[1]["seen"])[:100]: - if not conn["alive"]: - self.conns.pop(inode, None) - self.wake.wait(INTERVAL) - self.wake.clear() - - -def ordered(rows, mode): - if mode == "live": - return sorted(rows, key=lambda r: (-(r["up_rate"] + r["down_rate"]), - -(r["up"] + r["down"]))) - return sorted(rows, key=lambda r: (-(r["up"] + r["down"]), - -(r["up_rate"] + r["down_rate"]))) - - -def table(rows, w, limit, selected=-1): - """The process table, dropping columns rather than clipping them. - - Total is the one figure that cannot go: it is the whole question. Then - the combined rate, then the split into down and up, which is a detail - beside knowing something is moving at all. The name keeps a space of its - own so it never runs into the pid. - """ - avail = (w - 1) - 2 - 8 - 11 - wide = avail >= 10 + 11 + 22 - mid = avail >= 10 + 11 - name_w = max(8, min(26, avail - (33 if wide else 11 if mid else 0))) - - head = [(DIM, " " + pad("PROCESS", name_w)), (DIM, "%-8s" % "PID"), - (DIM, "%11s" % "TOTAL")] - if mid: - head.append((DIM, "%11s" % "NOW")) - if wide: - head.append((DIM, "%11s" % "DOWN")) - head.append((DIM, "%11s" % "UP")) - out = [seg(head, w - 1)] - - for i, row in enumerate(rows[:limit]): - live = row["up_rate"] + row["down_rate"] - total = row["up"] + row["down"] - gone = not row["alive"] - here = i == selected - tint = bg(28, 44, 62) if here else "" - line = [(tint + (ACCENT if here else DIM if gone else TXT), - ("▸" if here else " ") + " " - + pad(row["name"][:name_w - 2], name_w - 1)), - (tint + DIM, "%-8s" % (row["pid"] or "-")), - (tint + (TXT if total else DIM), "%11s" % units(total))] - if mid: - line.append((tint + (OK if live else DIM), "%11s" % rate(live))) - if wide: - line.append((tint + (DOWN if row["down_rate"] else DIM), - "%11s" % rate(row["down_rate"]))) - line.append((tint + (UP if row["up_rate"] else DIM), - "%11s" % rate(row["up_rate"]))) - if here: - line.append((tint, " " * w)) - out.append(seg(line, w - 1)) - return out - - -# A braille cell is two dots wide and four tall, so one character holds -# eight addressable points. The bit for each is fixed by the encoding: the -# glyph is U+2800 plus the mask of the dots that are lit. -BRAILLE = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80)) - - -def braille_canvas(values, peak, cols, rows, inverted=False): - """Plot a series on a dot canvas eight times finer than the cells. - - Two dots per column and four per row, which is the difference between a - line that steps between character rows and one that reads as a curve. - Consecutive samples are joined with Bresenham rather than left as - points, so a steep climb is a line and not a column of freckles. - """ - px_w, px_h = cols * 2, rows * 4 - grid = [[0] * cols for _ in range(rows)] - vals = list(values)[-px_w:] - if not vals: - return grid - - def point(i): - x = (px_w - 1 if len(vals) == 1 - else int(round(i * (px_w - 1) / float(len(vals) - 1)))) - scaled = min(1.0, max(0.0, vals[i] / peak)) if peak else 0.0 - magnitude = int(round(scaled * (px_h - 1))) - return x, (magnitude if inverted else px_h - 1 - magnitude) - - def dot(x, y): - if 0 <= x < px_w and 0 <= y < px_h: - grid[y // 4][x // 2] |= BRAILLE[y % 4][x % 2] - - if len(vals) == 1: - if vals[0] > 0: - dot(*point(0)) - return grid - for i in range(1, len(vals)): - # An idle stretch draws nothing at all rather than a flat line - # pinned to the axis, which would read as activity at zero. - if vals[i - 1] == 0 and vals[i] == 0: - continue - x0, y0 = point(i - 1) - x1, y1 = point(i) - dx, dy = abs(x1 - x0), -abs(y1 - y0) - sx = 1 if x0 < x1 else -1 - sy = 1 if y0 < y1 else -1 - err = dx + dy - while True: - dot(x0, y0) - if x0 == x1 and y0 == y1: - break - twice = 2 * err - if twice >= dy: - err += dy - x0 += sx - if twice <= dx: - err += dx - y0 += sy - return grid - - -def braille_row(masks, colour): - return [(colour, chr(0x2800 + m) if m else " ") for m in masks] - - -def chart(series, w, h, label="", tint=None): - """Sent above the line, received below it, newest on the right. - - That way round because of the arrows: ↑ means upload and ↓ means - download, so upload has to be the half that goes up. Drawing received - above an arrow pointing down asks the reader to hold two contradictory - directions at once, and they will believe the arrow. - - The two halves are scaled independently and each says what its own peak - is. A shared scale is the obvious choice and the wrong one here: a - download running at ten megabits with acknowledgements going back at - fifty kilobits would draw the upload as a flat nothing, and whether the - upload is flat is often the question. Each label names its own direction - and neither is signed: the lower half is drawn downward, which is a fact - about the drawing rather than about the number. - """ - canvas = max(2, h - 3) - up_h = max(1, canvas // 2) - down_h = max(1, canvas - up_h) - # Sized against the widest label this could need, so the slice does not - # change when the peak's text does. - window = list(series)[-max(12, w - 18) * 2:] - rx = [v[0] for v in window] - tx = [v[1] for v in window] - rx_peak = max(rx or [0]) or 1.0 - tx_peak = max(tx or [0]) or 1.0 - rx_hue, tx_hue = tint or (DOWN, UP) - # The label column is sized to the labels it must hold. A fixed width - # was fine until a peak wanted eleven characters, at which point the - # line grew by one and pushed the frame's closing corner off the edge. - # Each label carries its own direction, so a peak means something on - # its own rather than only in relation to the legend. Neither is - # negative: rx is not negative traffic, it is simply the half drawn - # downward, and a minus sign on it would be a fact about the drawing - # dressed up as a fact about the number. - up_label = "↑ " + rate(tx_peak) - down_label = "↓ " + rate(rx_peak) - lab = min(16, max(9, len(up_label), len(down_label))) - plot = max(12, w - lab - 4) - - out = [seg([(UP, "%*s " % (lab, up_label)), - (GRID, "┌" + "─" * plot + "┐")], w - 1)] - for masks in braille_canvas(tx, tx_peak, plot, up_h): - out.append(seg([(DIM, " " * (lab + 1)), (GRID, "│")] - + braille_row(masks, tx_hue) + [(GRID, "│")], w - 1)) - out.append(seg([(DIM, "%*s " % (lab, "0")), - (GRID, "├" + "─" * plot + "┤")], w - 1)) - for masks in braille_canvas(rx, rx_peak, plot, down_h, inverted=True): - out.append(seg([(DIM, " " * (lab + 1)), (GRID, "│")] - + braille_row(masks, rx_hue) + [(GRID, "│")], w - 1)) - out.append(seg([(DOWN, "%*s " % (lab, down_label)), - (GRID, "└" + "─" * plot + "┘")], w - 1)) - return out - - -def chart_head(series, w, label): - """The line above a chart: what it is, and how far back it reaches.""" - window = list(series) - span = elapsed(len(window) * INTERVAL) if window else "nothing yet" - return seg([(LBL, " ── %s ── " % label), - (UP, "↑ tx above"), (DIM, " · "), (DOWN, "↓ rx below"), - (DIM, " · %s of history" % span)], w - 1) - - -def short(path, room): - """A path that fits, keeping the end - which is the filename.""" - home = os.path.expanduser("~") - if path.startswith(home): - path = "~" + path[len(home):] - if len(path) <= room: - return path - return "…" + path[-(room - 1):] - - -def wrap(text, width): - lines, rest = [], text - while rest and len(lines) < 3: - if len(rest) <= width: - lines.append(rest) - break - cut = rest.rfind(" ", 0, width + 1) - cut = cut if cut > width // 2 else width - lines.append(rest[:cut]) - rest = rest[cut:].lstrip() - return lines or [""] - - -def field(label, value, w, colour=TXT): - out = [] - for i, line in enumerate(wrap(value, max(8, (w - 3) - 10))): - out.append(seg([(DIM, " " + pad(label if not i else "", 10)), - (colour, line)], w - 1)) - return out - - -SECTIONS = ("endpoints", "connections", "files") -# What `c` would put on the clipboard, set while drawing and read when the -# key is pressed - the selection is known in one place and used in another. -pending_copy = [""] - - -def section_head(name, count, note, focused, key, w): - """A section header that says whether it is the one taking the keys.""" - return seg([(ACCENT if focused else LBL, - (" ▏" if focused else " ") + "── %s ── " % name), - (DIM, "%d %s" % (count, note)), - (ACCENT if focused else GRID, " [%s]" % key)], w - 1) - - -def endpoint_rows(spots, at, focused, room, w): - """Remote hosts, ranked by what they have carried since launch.""" - out = [] - host_w = max(14, min(34, (w - 1) - 42)) - for i, spot in enumerate(spots[:max(1, room)]): - here = focused and i == at - name = hostname(spot["peer"]) or spot["peer"] - ports = "/".join(service(p) or str(p) for p in spot["ports"][:2]) - tint = bg(28, 44, 62) if here else "" - out.append(seg([ - (tint + (ACCENT if here else DIM), " ▸ " if here else " "), - (tint + (TXT if spot["alive"] else DIM), pad(name[:host_w - 1], - host_w)), - (tint + DIM, "%-9s" % ports[:9]), - (tint + DOWN, "↓%9s" % units(spot["down"])), - (tint + UP, " ↑%9s" % units(spot["up"])), - (tint + (OK if spot["down_rate"] + spot["up_rate"] else DIM), - "%11s" % rate(spot["down_rate"] + spot["up_rate"])), - (tint, " " * w if here else ""), - ], w - 1)) - return out - - -def connection_rows(conns, at, focused, room, w): - """The sockets open right now, which is a different list from the hosts.""" - out = [] - host_w = max(14, min(38, (w - 1) - 34)) - for i, conn in enumerate(conns[:max(1, room)]): - here = focused and i == at - tint = bg(28, 44, 62) if here else "" - where = "%s:%d" % (conn["peer"], conn["port"]) - out.append(seg([ - (tint + (ACCENT if here else DIM), " ▸ " if here else " "), - (tint + (TXT if conn["alive"] else DIM), pad(where[:host_w - 1], - host_w)), - (tint + (OK if conn["alive"] else DIM), - "%-7s" % ("open" if conn["alive"] else "closed")), - (tint + DOWN, "↓%9s" % units(conn["down"])), - (tint + UP, " ↑%9s" % units(conn["up"])), - (tint, " " * w if here else ""), - ], w - 1)) - return out - - -def file_rows(files, sizes, at, focused, room, w): - """Open files, with how fast each is growing since this screen opened.""" - out = [] - path_w = max(18, (w - 1) - 30) - for i, item in enumerate(files[:max(1, room)]): - here = focused and i == at - tint = bg(28, 44, 62) if here else "" - was = sizes.get(item["path"]) - grew = item["size"] - was[0] if was else 0 - span = time.time() - was[1] if was else 0 - out.append(seg([ - (tint + (ACCENT if here else DIM), " ▸ " if here else " "), - (tint + TXT, pad(short(item["path"], path_w), path_w)), - (tint + DIM, "%10s" % units(item["size"])), - (tint + (OK if grew > 0 else DIM), - "%12s" % (("+" + rate(grew / span)) if grew > 0 and span else "")), - (tint, " " * w if here else ""), - ], w - 1)) - return out - - -def detail_rows(row, hist, spots, conns, files, sizes, focus, at, w, h): - """One process in full: what it is, who it talks to, what it writes.""" - facts = process_facts(row["pid"]) - here_now = running(row["pid"]) - total = row["up"] + row["down"] - out = [title("%s · pid %d" % (row["name"], row["pid"]), w, ACCENT)] - out.append(seg([(TXT, " " + units(total)), - (DIM, " since first seen · "), - (DOWN, "↓ " + rate(row["down_rate"])), (DIM, " "), - (UP, "↑ " + rate(row["up_rate"]))], w - 1)) - if not row["pid"]: - out.append(seg([(DIM, " another user's process - named from its " - "control group, since /proc is closed to us")], - w - 1)) - elif not here_now: - out.append(seg([(WARN, " this process has exited - its total is kept," - " and nothing below is live")], w - 1)) - elif not row["alive"]: - out.append(seg([(DIM, " no connection open at the moment - what is " - "below is the last that was seen")], w - 1)) - out.append("") - - # This process's own traffic, on the same chart as the machine's. - spare = h - len(out) - graph_h = 7 if spare >= 30 else 5 if spare >= 24 else 0 - if graph_h and hist: - out.append(chart_head(hist, w, "THIS PROCESS")) - out.extend(chart(hist, w, graph_h)) - out.append("") - - if h - len(out) >= 12: - out.append(seg([(LBL, " ── PROCESS ── ")], w - 1)) - out += field("command", facts["cmdline"] or "?", w) - if facts["cwd"]: - out += field("directory", short(facts["cwd"], w - 14), w) - out.append("") - - # What is left is split between the three lists, with the focused one - # given the room: it is the one being read, and the others still say - # how much they are holding in their headers. - left = max(3, h - len(out) - 4) - shares = {name: 1 for name in SECTIONS} - shares[focus] = max(1, left - 2 - 3 * 2) - counts = {"endpoints": len(spots), "connections": len(conns), - "files": len(files)} - - for name, key, note in (("TALKING TO", "e", "endpoint"), - ("CONNECTIONS", "tab", "socket"), - ("FILES", "f", "file")): - which = SECTIONS[("TALKING TO", "CONNECTIONS", - "FILES").index(name)] - n = counts[which] - focused = focus == which - out.append(section_head(name, n, note + ("" if n == 1 else "s"), - focused, key, w)) - room = min(shares[which], max(1, h - len(out) - 3)) - if not n: - out.append(seg([(DIM, " none")], w - 1)) - elif which == "endpoints": - out.extend(endpoint_rows(spots, at[which], focused, room, w)) - # The highlighted host gets its own small chart, which is the - # quickest way to see whether it is the one doing the work. - pick = spots[min(at[which], len(spots) - 1)] if spots else None - if focused and pick and pick["hist"] and h - len(out) >= 7: - out.append(seg([(DIM, " ── "), - (ACCENT, hostname(pick["peer"]) - or pick["peer"]), - (DIM, " alone ──")], w - 1)) - out.extend(chart(pick["hist"], w, 4)) - elif which == "connections": - out.extend(connection_rows(conns, at[which], focused, room, w)) - else: - out.extend(file_rows(files, sizes, at[which], focused, room, w)) - out.append("") - - io = proc_io(row["pid"]) if here_now else {} - if io and h - len(out) >= 2: - out.append(seg([(LBL, " ── DISK ── "), - (DIM, "read %s · written %s since it started" - % (units(io.get("read_bytes", 0)), - units(io.get("write_bytes", 0))))], w - 1)) - return out - - -def plain_line(rows, started, mode, limit): - """One block per interval, for a log or a pipe.""" - lines = ["--- %s elapsed · sorted by %s ---" - % (elapsed(time.time() - started), mode)] - for row in rows[:limit]: - lines.append("%-22s %-8s %11s %11s %11s %11s" - % (row["name"], row["pid"] or "-", - units(row["up"] + row["down"]), - rate(row["up_rate"] + row["down_rate"]), - rate(row["down_rate"]), rate(row["up_rate"]))) - return "\n".join(lines) - - -def parse_args(argv): - global INTERVAL, LIMIT, SORT, EXTERNAL, PLAIN, MINE - rest = list(argv) - while rest: - arg = rest.pop(0) - if arg in ("-i", "--interval") and rest: - INTERVAL = max(0.2, float(rest.pop(0))) - elif arg in ("-n", "--limit") and rest: - LIMIT = max(0, int(rest.pop(0))) - elif arg == "--sort" and rest: - want = rest.pop(0) - if want not in ("total", "live"): - sys.stderr.write("--sort takes total or live\n") - raise SystemExit(2) - SORT = want - elif arg == "--external": - EXTERNAL = True - elif arg == "--all-external": - EXTERNAL = False - elif arg == "--all-users": - MINE = False - elif arg in ("-V", "--version"): - print("netwatch.py %s" % VERSION) - raise SystemExit(0) - elif arg == "--plain": - PLAIN = True - else: - sys.stderr.write("unknown option %r - try --help\n" % arg) - raise SystemExit(2) - - -def main(): - global MINE - maybe_help(__doc__) - parse_args(sys.argv[1:]) - absent = missing("ss") - if absent: - cannot_start( - "netwatch", absent, - ["ss reports the per-socket byte counters this is built on:", - "how much each TCP connection has carried, and the inode that", - "ties it to a process. Without it there is nothing to read.", - "", - "It ships in iproute2, which is on essentially every Linux", - "system - its absence usually means a very small container", - "image rather than a missing package."], - "apt install iproute2") - - store = Store() - threading.Thread(target=store.run, daemon=True).start() - mode = SORT - - if PLAIN: - while True: - time.sleep(INTERVAL) - rows, started, err = store.snapshot() - if err: - sys.stderr.write(err + "\n") - if MINE: - rows = [r for r in rows if r["pid"]] - print(plain_line(ordered(rows, mode), started, mode, - LIMIT or len(rows))) - sys.stdout.flush() - - setup() - keyboard = Keyboard() - threading.Thread(target=resolver, daemon=True).start() - selected, detail, sizes = 0, None, {} - focus, at = "endpoints", {k: 0 for k in SECTIONS} - notice = None - while True: - w, h = size() - rows, started, err = store.snapshot() - # A row with no pid is a process we do not own: /proc would not name - # it, which is the same test as "not ours". - if MINE: - rows = [r for r in rows if r["pid"]] - rows = ordered(rows, mode) - - for key in keyboard.poll(): - if detail is not None: - if key in ("esc", "left", "q", "Q", "backspace"): - detail, sizes = None, {} - elif key in ("r", "R"): - store.reset() - detail, sizes = None, {} - elif key in ("up", "k", "K"): - at[focus] -= 1 - elif key in ("down", "j", "J"): - at[focus] += 1 - elif key == "tab": - focus = SECTIONS[(SECTIONS.index(focus) + 1) - % len(SECTIONS)] - elif key in ("e", "E"): - focus = "endpoints" - elif key in ("f", "F"): - focus = "files" - elif key in ("c", "C"): - text = pending_copy[0] - if text: - ok = clipboard(text) - # The value goes in the message either way: OSC 52 - # is refused by some terminals and swallowed by some - # multiplexers, and a copy that quietly did nothing - # would leave nothing on screen to read instead. - notice = (("copied " if ok else "no clipboard ") - + text, OK if ok else WARN, time.time() + 8) - continue - if key in ("q", "Q"): - raise SystemExit(0) - if key == "1": - mode = "total" - elif key == "2": - mode = "live" - elif key in ("up", "k", "K"): - selected -= 1 - elif key in ("down", "j", "J"): - selected += 1 - elif key in ("s", "S", "t", "T"): - mode = "live" if mode == "total" else "total" - elif key in ("o", "O"): - MINE = not MINE - selected = 0 - elif key in ("enter", "right", "i") and rows: - pick = rows[max(0, min(selected, len(rows) - 1))] - detail, sizes = (pick["pid"], pick["name"]), {} - focus, at = "endpoints", {k: 0 for k in SECTIONS} - elif key in ("r", "R"): - store.reset() - selected = 0 - - selected = max(0, min(selected, len(rows) - 1)) if rows else 0 - - # One process in full. Its row is looked up fresh each frame so the - # figures keep moving while the screen is open, and it survives the - # process exiting - which is often when it is being looked at. - if detail is not None: - pid, name = detail - row = next((r for r in rows - if r["pid"] == pid and r["name"] == name), None) - if row is None: - detail, sizes = None, {} - else: - spots = store.endpoints(pid, name) - conns = store.connections(pid, name) - files = open_files(pid) if running(pid) else [] - now = time.time() - for item in files: - if item["path"] not in sizes: - sizes[item["path"]] = (item["size"], now) - sizes_of = {"endpoints": len(spots), - "connections": len(conns), "files": len(files)} - for which in SECTIONS: - at[which] = max(0, min(at[which], - sizes_of[which] - 1)) \ - if sizes_of[which] else 0 - # What c would copy, decided where the selection is rather - # than at the keypress, so the footer can name it. - if focus == "endpoints" and spots: - pick = spots[at["endpoints"]] - pending_copy[0] = hostname(pick["peer"]) or pick["peer"] - elif focus == "connections" and conns: - pick = conns[at["connections"]] - pending_copy[0] = "%s:%d" % (pick["peer"], pick["port"]) - elif focus == "files" and files: - pending_copy[0] = files[at["files"]]["path"] - else: - pending_copy[0] = "" - - foot = [" " + line for line in pack_hints( - [[(ACCENT, "↑↓"), (DIM, " in section")], - [(ACCENT, "tab"), (DIM, " section")], - [(DIM, "[e]ndpoints")], [(DIM, "[f]iles")], - [(DIM, "[c]opy")], [(DIM, "[esc] back")], - [(DIM, "[q]uit")]], w - 2)] - if notice and time.time() >= notice[2]: - notice = None - if notice: - foot = [seg([(notice[1], " " + notice[0])], w - 1)] - room = max(1, h - len(foot) - 1) - body = detail_rows(row, store.history(pid, name), spots, - conns, files, sizes, focus, at, w, room) - while len(body) < room: - body.append("") - draw(body[:room] + foot, w, h) - time.sleep(min(0.5, INTERVAL)) - continue - - moving = sum(1 for r in rows if r["up_rate"] + r["down_rate"]) - down = sum(r["down_rate"] for r in rows) - up = sum(r["up_rate"] for r in rows) - - out = [title("netwatch", w, ACCENT)] - out.append(seg([(DIM, " %d process%s" % (len(rows), - "" if len(rows) == 1 - else "es")), - (DIM, " · %d moving" % moving), - (DIM, " · "), (ACCENT, elapsed(time.time() - started)), - (DIM, " · sorted by "), (ACCENT, mode), - (DIM, " every %gs" % INTERVAL)], w - 1)) - out.append(seg([(DIM, " TCP only · " if MINE - else " TCP only · every user · "), - (DOWN, "↓ " + rate(down)), (DIM, " "), - (UP, "↑ " + rate(up)), - (DIM, " · internet only" if EXTERNAL - else " · everything off-box")], w - 1)) - # What the network card moved, beside what the sockets explain. A - # machine that routes - an exit node, a subnet router, a container - # host - passes traffic that never touches a socket here, and a - # process list presented as the whole picture would be a lie by - # omission on exactly the machines where it matters most. - wire_rx, wire_tx, wire_names = store.wire_now() - wire = wire_rx + wire_tx - if wire > 0: - share = min(1.0, (down + up) / wire) - said = [(LBL, " interfaces"), (DIM, " · "), - (DOWN, "↓ " + rate(wire_rx)), (DIM, " "), - (UP, "↑ " + rate(wire_tx)), (DIM, " · "), - (DIM if share >= 0.9 else WARN, - "%.0f%% of it has a socket" % (share * 100))] - # The percentage is the signal; the sentence explaining it is a - # courtesy, and is dropped rather than truncated mid-word. - tail = " · the rest is routed through, not sent by anything here" - if share < 0.9 and sum(len(t) for _, t in said) + len(tail) <= w - 1: - said.append((DIM, tail)) - which = wire_label(wire_names) - if which: - room = (w - 1) - sum(len(t) for _, t in said) - 3 - if len(which) <= room: - said.append((GRID, " · " + which)) - out.append(seg(said, w - 1)) - if err: - out.append(seg([(BAD, " ! " + err)], w - 1)) - out.append("") - - # The chart takes a share of the pane and the list takes the rest, - # but the list is the point: below a certain height there is no - # chart at all rather than two rows of neither. - spare = h - len(out) - 4 - graph_h = 9 if spare >= 20 else 7 if spare >= 15 else 5 if spare >= 11 else 0 - if graph_h and store.rates(MINE): - # The chart is the sum of the rows below it, so it is named - # for them. Calling it "traffic" claimed the whole machine's, - # which is the one thing it is not - that is the interfaces - # line, and the two disagreeing is the point of having both. - # Which processes are counted is on the line above; the chart - # keeps one name rather than renaming itself under the cursor. - out.append(chart_head(store.rates(MINE), w, "PROCESS WATCH")) - out.extend(chart(store.rates(MINE), w, graph_h)) - out.append("") - - room = max(1, h - len(out) - 3) - limit = min(LIMIT or room, room) - if selected >= limit: - rows = rows[selected - limit + 1:] - if not rows: - out.append(seg([(DIM, " Nothing has moved yet. Totals start at " - "zero, so this fills as traffic " - "happens.")], w - 1)) - else: - out.extend(table(rows, w, limit, selected)) - - while len(out) < h - 2: - out.append("") - hints = [[(ACCENT, "↑↓"), (DIM, " select")], - [(ACCENT, "↵"), (DIM, " details")], - [(ACCENT if mode == "total" else DIM, "[1] total")], - [(ACCENT if mode == "live" else DIM, "[2] live")], - [(DIM, "[o]%s others" % ("show" if MINE else "hide"))], - [(DIM, "[r]ezero")], [(DIM, "[q]uit")]] - for line in pack_hints(hints, w - 2): - out.append(" " + line) - draw(out, w, h) - time.sleep(min(0.3, INTERVAL)) - - -main() diff --git a/ports.py b/ports.py deleted file mode 100755 index 303efc7..0000000 --- a/ports.py +++ /dev/null @@ -1,1350 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""What is listening on this machine, what started it, and who can reach it. - -On a box running several agents at once, "which port is that project on" and -"is this reachable from outside" are asked constantly and answered badly. -`lsof -i` gives a pid and a port and stops there. - -Each row is one listening service: the port, what it is bound to, what kind of -server it is, the project directory it was started from - the label that -actually identifies a dev server - how long it has been up, and whether -anything outside this machine can reach it. A server listening on both IPv4 -and IPv6 is one row, not two. - - python3 ports.py [-n SECONDS] - -Read from /proc alone: the socket table for the ports, and each process's own -cmdline and cwd for the rest. Exposure comes from `tailscale serve status` -where Tailscale is installed. Another user's sockets cannot be tied to a -process without root, so those rows name the owner the socket table gives -and are hidden behind o along with the system ports. - -k stops the selected server, after a confirmation and only for a process you -own: SIGTERM to its process group, which is what Ctrl-C in its own terminal -would have sent, then the offer of SIGKILL if it is still up three seconds -later. - -Enter opens a second screen for one port, where there is more to show than the -table holds: the command behind it, every address it can actually be reached -at - bounded by what the socket is bound to - and c to copy one. From there s -and t publish it over Tailscale, to the tailnet or to the internet, and d -opens a Cloudflare quick tunnel. Each asks first. - -Keys: up/down select, enter opens, esc goes back, c copies, s serves, -t funnels, d tunnels, k kills, o hides the machine's own ports, r refreshes, -q quits. -""" -import getpass -import json -import os -import pwd -import re -import shutil -import signal -import socket -import struct -import subprocess -import sys -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (Keyboard, bg, clipboard, draw, load_config, maybe_help, - pack_hints, pad, rgb, seg, setup, size, title) - -_CFG = load_config("ports", { - # Ports that are part of the machine rather than something you started. - # Hidden behind `o` by default: they are never the answer to "which port - # is my dev server on". - "system_ports": [22, 53, 123, 323, 631, 5353], - "refresh": 4, -}) - -REFRESH = max(1.0, float(_CFG["refresh"])) -SYSTEM_PORTS = set(int(p) for p in (_CFG["system_ports"] or [])) - -OK = rgb(90, 240, 160) -WARN = rgb(255, 200, 90) -BAD = rgb(255, 100, 110) -DIM = rgb(127, 147, 172) -GRID = rgb(60, 78, 98) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) -PORT = rgb(160, 220, 255) -OPEN = rgb(255, 170, 120) # bound to every interface -LOCAL = rgb(120, 200, 160) # loopback only - -# Process titles worth recognising. Checked in order, first match wins, so -# the specific ones come before `node` and `python`. -KINDS = ( - (r"^next-server", "Next.js"), - (r"node_modules/\.bin/vite|[/ ]vite(\s|$)", "Vite"), - (r"node_modules/next/dist", "Next.js"), - (r"react-scripts", "React"), - (r"webpack", "webpack"), - (r"nuxt", "Nuxt"), - (r"astro", "Astro"), - (r"remix", "Remix"), - (r"turbo(\s|$)", "Turborepo"), - (r"uvicorn", "uvicorn"), - (r"gunicorn", "gunicorn"), - (r"flask", "Flask"), - (r"django|manage\.py", "Django"), - (r"rails", "Rails"), - (r"postgres", "Postgres"), - (r"redis-server", "Redis"), - (r"mysqld|mariadb", "MySQL"), - (r"mongod", "MongoDB"), - (r"docker-proxy", "Docker"), - (r"ollama", "Ollama"), - (r"code-server|vscode-server", "VS Code"), - (r"^herdr|/herdr", "Herdr"), - (r"tailscaled", "Tailscale"), - (r"sshd", "SSH"), - (r"systemd-resolve", "DNS"), - (r"[/ ]python[0-9.]*(\s|$)", "Python"), - (r"[/ ]node(\s|$)", "Node"), -) - -# Ports whose owner is usually root, so /proc will not say what it is. Naming -# them by convention is a guess, and is marked as one. -BY_PORT = {22: "SSH", 53: "DNS", 80: "HTTP", 123: "NTP", 443: "HTTPS", - 631: "printing", 3306: "MySQL", 5432: "Postgres", 6379: "Redis", - 5353: "mDNS", 27017: "MongoDB"} - -VERSION = re.compile(r"\(v([0-9][0-9a-zA-Z.\-]*)\)") -# Tailscale hands out 100.64.0.0/10 and fd7a:115c:a1e0::/48. A socket bound -# to one is reachable by tailnet peers and nobody else, which is a different -# answer from either "all" or "local". -TAILNET_V4 = re.compile(r"^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\.") - - -def run(args): - try: - out = subprocess.run(args, capture_output=True, text=True, timeout=5) - except (OSError, subprocess.SubprocessError): - return "" - return out.stdout if out.returncode == 0 else "" - - -def hex_addr(text, family): - """The bind address from /proc's little-endian hex.""" - try: - if family == socket.AF_INET: - return socket.inet_ntoa(struct.pack(" pid, for every process this user can read. - - Root's sockets are not readable, so sshd and the like arrive unowned. - That is stated on screen rather than papered over: a widget claiming to - know what is behind port 22 when it cannot is worse than one that says - so. - - The pid is an int, not the string /proc was listed as: it is handed to - os.kill when a row is stopped, and that takes no strings. - """ - owners = {} - for pid in os.listdir("/proc"): - if not pid.isdigit(): - continue - try: - fds = os.listdir("/proc/%s/fd" % pid) - except OSError: - continue - for fd in fds: - try: - target = os.readlink("/proc/%s/fd/%s" % (pid, fd)) - except OSError: - continue - if target.startswith("socket:["): - owners[target[8:-1]] = int(pid) - return owners - - -def project_name(cwd): - """What a directory calls itself, for the label a person would use. - - A deleted directory still names the project it was - a dev server left - running on a worktree that has since been removed is exactly the thing - this widget should surface, and blanking the column hides it. - """ - if cwd and cwd.endswith(" (deleted)"): - return os.path.basename(cwd[:-10].rstrip("/")) + " ✗" - if not cwd: - return "" - try: - with open(os.path.join(cwd, "package.json")) as f: - name = (json.load(f) or {}).get("name") - if name: - return str(name) - except (OSError, ValueError): - pass - return os.path.basename(cwd.rstrip("/")) - - -def kind_of(cmdline, port): - """What sort of server this is. - - From the process title where there is one - Next.js rewrites its own to - `next-server (v16.3.0)`, which is the version as well as the name - and - from the argv path when the title is just `node`, since a dev server - launched through a package manager is several layers of wrapper deep. - """ - if not cmdline: - guess = BY_PORT.get(port) - return ("%s?" % guess, True) if guess else ("", True) - for pattern, name in KINDS: - if re.search(pattern, cmdline): - found = VERSION.search(cmdline) - return ("%s %s" % (name, found.group(1)) if found else name), False - return os.path.basename(cmdline.split()[0]), False - - -def process_info(pid): - try: - with open("/proc/%s/cmdline" % pid, "rb") as f: - cmdline = f.read().replace(b"\x00", b" ").decode( - "utf8", "replace").strip() - except OSError: - cmdline = "" - try: - cwd = os.readlink("/proc/%s/cwd" % pid) - except OSError: - cwd = "" - try: - started = os.stat("/proc/%s" % pid).st_ctime - except OSError: - started = None - return cmdline, cwd, started - - -def interfaces(): - """Every address this machine holds, by interface. - - Link-local is dropped: an fe80:: address needs a zone index to be usable - and is never what somebody wants pasted into a browser. - """ - found = [] - try: - data = json.loads(run(["ip", "-j", "addr"]) or "[]") - except ValueError: - return found - for link in data: - for addr in link.get("addr_info") or []: - ip = addr.get("local") or "" - if not ip or ip.startswith("fe80:"): - continue - found.append((link.get("ifname") or "?", ip, - addr.get("family") == "inet6")) - return found - - -def tailnet_self(): - """This node's tailnet name and addresses, and whether it may Funnel. - - Funnel is off unless the tailnet's policy grants the node the attribute, - and the node knows: the capability is in the map the coordination server - hands it. Asking here means the widget can say so instead of offering a - key that only ever returns an error. - """ - out = {"name": "", "ips": [], "funnel": False, "operator": False} - try: - data = json.loads(run(["tailscale", "status", "--json"]) or "null") - except ValueError: - return out - self_node = (data or {}).get("Self") or {} - out["name"] = (self_node.get("DNSName") or "").rstrip(".") - out["ips"] = list(self_node.get("TailscaleIPs") or []) - out["funnel"] = any("cap/funnel" in cap - for cap in (self_node.get("CapMap") or {})) - # Changing the serve config is a root operation unless this user has been - # named the operator. Worth knowing before the key is pressed, since the - # fix is a one-off command rather than anything the widget can do. - try: - prefs = json.loads(run(["tailscale", "debug", "prefs"]) or "null") - except ValueError: - prefs = None - who = (prefs or {}).get("OperatorUser") - out["operator"] = os.getuid() == 0 or who == getpass.getuser() - return out - - -def host_part(ip, v6): - """An address as it goes in a URL - IPv6 needs its brackets.""" - return "[%s]" % ip if v6 else ip - - -def url_for(host, v6, port): - """A URL for a host and port, with the scheme the port implies.""" - scheme = "https" if port in (443, 8443) else "http" - return "%s://%s:%d" % (scheme, host_part(host, v6), port) - - -def addresses(row, net, cfg): - """Where this port can actually be reached, most local first. - - Bounded by what the socket is bound to, which is the part that gets got - wrong: a server on 127.0.0.1 is not reachable at this machine's LAN - address no matter how many addresses the machine has, and offering one - to copy would hand somebody a URL that cannot work. Only a socket bound - to every interface gets the full list. - - A served port is the exception worth keeping: Tailscale proxies to it - over loopback, so its https URL works even for a loopback-only server. - """ - port, reach = row["port"], bind_class(row["bind"]) - found = [] - url = served_url(cfg, port) - if url: - found.append((url, "tailnet · via serve")) - # Nothing is listening, so every address below would refuse the - # connection. The serve URL above is the only one that exists, and it - # answers 502 - which is the whole reason this row is on screen. - if row.get("orphan"): - return found - if reach == "local": - found.append((url_for("127.0.0.1", False, port), "this machine only")) - return found - if reach == "tailnet": - for ip in net["ips"]: - found.append((url_for(ip, ":" in ip, port), "tailnet")) - if net["name"]: - found.append((url_for(net["name"], False, port), "tailnet · name")) - return found - if reach != "all": - # Bound to one particular address, so that address is the answer. - found.append((url_for(row["bind"], ":" in row["bind"], port), - "this interface")) - return found - found.append((url_for("127.0.0.1", False, port), "this machine")) - tail = set(net["ips"]) - for name, ip, v6 in interfaces(): - if ip.startswith("127.") or ip == "::1": - continue - found.append((url_for(ip, v6, port), "tailnet" if ip in tail else name)) - if net["name"]: - found.append((url_for(net["name"], False, port), "tailnet · name")) - return found - - -def serve_config(): - """Tailscale's own serve configuration, as it reports it. - - The JSON form rather than the text: the detail view needs the URL a - served port answers on, and putting a second port behind the same node - needs to know which mounts are already taken. Both are structure the - text output only implies. - """ - try: - return json.loads(run(["tailscale", "serve", "status", "--json"]) - or "null") or {} - except ValueError: - return {} - - -def served_url(cfg, port): - """The https URL a served port answers on, where one is configured.""" - for mount, web in (cfg.get("Web") or {}).items(): - for path, handler in (web.get("Handlers") or {}).items(): - proxy = handler.get("Proxy") or "" - if re.search(r":%d(/|$)" % port, proxy): - host, _, listen = mount.partition(":") - return "https://%s%s%s" % ( - host, "" if listen in ("", "443") else ":" + listen, - path if path != "/" else "/") - return "" - - -def exposure(): - """Ports Tailscale is serving, and whether the world can see them. - - `serve` is tailnet-only; `funnel` is public and limited to three ports. - Both are reported by the same command, so the funnel list is what - separates them. - """ - served, public = {}, set() - cfg = serve_config() - for web in (cfg.get("Web") or {}).values(): - for handler in (web.get("Handlers") or {}).values(): - found = re.search(r"https?://(?:127\.0\.0\.1|localhost|\[::1\]" - r"|::1):(\d+)", handler.get("Proxy") or "") - if found: - served[int(found.group(1))] = "tailnet" - funnel = run(["tailscale", "funnel", "status"]) - if "tailnet only" not in funnel: - for line in funnel.splitlines(): - found = re.search(r"proxy\s+https?://(?:127\.0\.0\.1|localhost)" - r":(\d+)", line) - if found: - public.add(int(found.group(1))) - for port in public: - served[port] = "public" - return served - - -def span(seconds): - if seconds is None: - return "--" - s = max(0, int(seconds)) - if s < 60: - return "%ds" % s - if s < 3600: - return "%dm" % (s // 60) - if s < 86400: - return "%dh" % (s // 3600) - return "%dd" % (s // 86400) - - -def scan(): - """One entry per listening service, plus anything served but not bound.""" - owners = socket_owners() - served = exposure() - rows, seen, services = [], set(), {} - now = time.time() - for sock in listening(): - pid = owners.get(sock["inode"]) - # A server that listens on both address families is two sockets in - # the kernel table but one thing to know about: 0.0.0.0 alongside ::, - # or a tailnet IPv4 address alongside its IPv6. Same port, same owner - # and the same answer to "who can reach it" means one row. Any of the - # three differing is a real second row - 127.0.0.1:8080 and - # 100.x:8080 are not the same answer even from the same process. - key = (sock["port"], pid, bind_class(sock["bind"])) - if key in services: - services[key]["families"] += 1 - continue - cmdline, cwd, started = process_info(pid) if pid else ("", "", None) - kind, guessed = kind_of(cmdline, sock["port"]) - row = { - "port": sock["port"], "bind": sock["bind"], "families": 1, - "pid": pid, "cmdline": cmdline, "cwd": cwd, - "kind": kind, "guessed": guessed, "user": owner_name(sock["uid"]), - "project": project_name(cwd), - "gone": cwd.endswith("(deleted)") if cwd else False, - "up": (now - started) if started else None, - "exposed": served.get(sock["port"], ""), - } - services[key] = row - rows.append(row) - seen.add(sock["port"]) - # A port Tailscale forwards to with nothing behind it is worth its own - # row: the URL exists, answers 502, and nothing in `lsof` explains why. - for port, how in served.items(): - if port not in seen: - rows.append({"port": port, "bind": "", "families": 0, "pid": None, - "cmdline": "", "cwd": "", "kind": "nothing listening", - "guessed": False, "project": "", "gone": False, - "user": "", "up": None, "exposed": how, - "orphan": True}) - rows.sort(key=lambda r: (r["port"] in SYSTEM_PORTS, r["port"])) - return rows - - -class Store(object): - def __init__(self): - self.lock = threading.Lock() - self.rows = [] - self.error = None - self.fetched = 0 - self.wake = threading.Event() - - def snapshot(self): - with self.lock: - return list(self.rows), self.fetched, self.error - - def run(self): - # A dead poller looks exactly like a machine with nothing running on - # it, which is a plausible enough state to be believed. - try: - self.poll() - except Exception as e: - with self.lock: - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:70]) - - def poll(self): - while True: - rows = scan() - with self.lock: - self.rows = rows - self.fetched = time.time() - self.wake.wait(REFRESH) - self.wake.clear() - - -def tunnel_dir(): - """Where a launched quick tunnel's pid and URL are remembered. - - cloudflared holds no listening socket - it dials out - so nothing in - /proc ties it to the port it serves. Without a note on disk the widget - would lose a tunnel the moment it restarted, and leave it running with - no way to find or stop it. - """ - base = (os.environ.get("XDG_STATE_HOME") - or os.path.expanduser("~/.local/state")) - path = os.path.join(base, "terminal-toys", "tunnels") - try: - os.makedirs(path, exist_ok=True) - except OSError: - return "" - return path - - -def tunnel_state(port): - """The quick tunnel for a port: its pid, its URL, whether it still runs.""" - path = tunnel_dir() - if not path: - return None - try: - with open(os.path.join(path, "%d.json" % port)) as f: - note = json.load(f) - except (OSError, ValueError): - return None - if not alive(note.get("pid") or 0): - forget_tunnel(port) - return None - return note - - -def forget_tunnel(port): - try: - os.remove(os.path.join(tunnel_dir(), "%d.json" % port)) - except OSError: - pass - - -def start_tunnel(port, wait=25.0): - """Run a cloudflared quick tunnel for a port and return its URL. - - A quick tunnel needs no account and no DNS: cloudflared picks a random - trycloudflare.com name and prints it. A named tunnel on a domain of your - own needs credentials and a DNS record, which is a setup task rather - than a keypress, and is deliberately not attempted here. - """ - path = tunnel_dir() - if not path: - return "", "no state directory to record the tunnel in" - log = os.path.join(path, "%d.log" % port) - try: - handle = open(log, "w+b") - except OSError as exc: - return "", exc.strerror or "cannot write the log" - try: - proc = subprocess.Popen( - ["cloudflared", "tunnel", "--no-autoupdate", - "--url", "http://127.0.0.1:%d" % port], - stdout=handle, stderr=subprocess.STDOUT, - stdin=subprocess.DEVNULL, start_new_session=True) - except OSError as exc: - handle.close() - return "", exc.strerror or "cloudflared would not start" - deadline = time.time() + wait - found = "" - while time.time() < deadline and not found: - time.sleep(0.4) - try: - with open(log, "rb") as f: - text = f.read().decode("utf8", "replace") - except OSError: - text = "" - match = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", text) - if match: - found = match.group(0) - elif not alive(proc.pid): - break - handle.close() - if not found: - end(proc.pid, signal.SIGTERM) - return "", "no URL after %ds - see %s" % (int(wait), log) - try: - with open(os.path.join(path, "%d.json" % port), "w") as f: - json.dump({"pid": proc.pid, "url": found, "port": port}, f) - except OSError: - pass - return found, "" - - -def alive(pid): - """Whether a pid is still running. Signal 0 checks without delivering. - - A zombie answers signal 0 and is not running: it is an exit status its - parent has not collected yet. Reporting one as alive would leave the - widget offering to SIGKILL something already dead, forever, since no - signal moves a zombie. /proc knows the difference. - """ - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except OSError: - pass - try: - with open("/proc/%d/stat" % pid) as f: - return f.read().rsplit(")", 1)[1].split()[0] != "Z" - except (OSError, IndexError): - return True - - -def killable(row): - """Whether this row can be signalled, and why not when it cannot. - - Returns (pid, reason). Only one of the two is ever set. The checks are - all about not doing damage past what was asked for: a row whose owner - /proc would not name is somebody else's process, and a process in this - widget's own group cannot be group-killed without taking the widget - down with it. - """ - pid = row.get("pid") - if not pid: - return None, "not yours - no owner for this socket in /proc" - if pid <= 1: - return None, "refusing to signal pid %d" % pid - try: - if os.stat("/proc/%d" % pid).st_uid != os.getuid(): - return None, "pid %d is not yours" % pid - except OSError: - return None, "pid %d is already gone" % pid - return pid, "" - - -def end(pid, sig): - """Signal the process group, falling back to the process alone. - - A dev server is rarely one process: `npm run dev` is a shell, a package - manager and the server itself, sharing a process group precisely so that - Ctrl-C reaches all three. Signalling the group is what Ctrl-C does. The - fallback covers a process whose group we cannot read, and the guard - covers the case where the group is this widget's own. - """ - try: - group = os.getpgid(pid) - except OSError: - group = None - try: - if group is not None and group != os.getpgrp(): - os.killpg(group, sig) - else: - os.kill(pid, sig) - except ProcessLookupError: - return "already gone" - except PermissionError: - return "not permitted" - except OSError as exc: - return exc.strerror or "failed" - return "" - - -def have(program): - """Whether a command exists, so a key can say so instead of failing.""" - return bool(shutil.which(program)) - - -# Tailscale accepts Funnel traffic on these three public ports and no -# others, so a node can have three funnels at once - not the one that -# defaulting to 443 every time would suggest. -FUNNEL_PORTS = (443, 8443, 10000) - - -def taken_ports(cfg): - """The tailnet-side ports this node's serve config already occupies.""" - used = set() - for key in (cfg.get("TCP") or {}): - try: - used.add(int(key)) - except (TypeError, ValueError): - continue - for mount in (cfg.get("Web") or {}): - _, _, listen = mount.rpartition(":") - try: - used.add(int(listen)) - except ValueError: - continue - return used - - -def free_funnel_port(cfg): - """The first public port free to funnel on, or 0 when all three are used.""" - used = taken_ports(cfg) - return next((p for p in FUNNEL_PORTS if p not in used), 0) - - -def serve_port(port, public=False, cfg=None): - """Put a local port behind this node's HTTPS name. - - Serve listens on the port's own number rather than 443. Nothing forces - that, but 443 is where an unflagged `tailscale serve` lands, so leaving - it as the default would mean the second port published quietly took the - first one's mount. - - Funnel has only the three ports Tailscale accepts from the internet, so - it takes the first of them that is free - a node can hold three at once, - and defaulting all of them to 443 would allow one. - """ - listen = port - if public: - listen = free_funnel_port(serve_config() if cfg is None else cfg) - if not listen: - return ("all three funnel ports are in use (%s) - stop one first" - % ", ".join(str(p) for p in FUNNEL_PORTS)) - cmd = ["tailscale", "funnel" if public else "serve", "--bg"] - cmd += ["--https=%d" % listen, str(port)] - try: - done = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - except (OSError, subprocess.SubprocessError) as exc: - return str(exc) or "tailscale would not run" - if done.returncode: - text = (done.stderr or done.stdout or "").strip() - return " ".join(text.split())[:200] or "tailscale refused" - return "" - - -def listen_for(cfg, port): - """Which tailnet-side port a local port is currently published on.""" - for mount, web in (cfg.get("Web") or {}).items(): - for handler in (web.get("Handlers") or {}).values(): - if re.search(r":%d(/|$)" % port, handler.get("Proxy") or ""): - _, _, listen = mount.rpartition(":") - try: - return int(listen) - except ValueError: - return 0 - return 0 - - -def unserve_port(port, public=False, cfg=None): - """Take back one port, leaving every other mount as it was. - - The mount to remove is looked up rather than assumed: it was chosen when - the port was published, and on a funnel that is whichever of the three - public ports happened to be free at the time. - - Never `serve reset`: that clears the whole configuration, including - whatever was already published before this widget was ever run. - """ - listen = listen_for(serve_config() if cfg is None else cfg, port) - if not listen: - listen = 443 if public else port - cmd = ["tailscale", "funnel" if public else "serve", - "--https=%d" % listen, "off"] - try: - done = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - except (OSError, subprocess.SubprocessError) as exc: - return str(exc) or "tailscale would not run" - if done.returncode: - text = (done.stderr or done.stdout or "").strip() - return " ".join(text.split())[:200] or "tailscale refused" - return "" - - -def kill_label(row, room=999): - """What a kill would take down, in whatever room the pane has. - - Everything here identifies the target, but not equally: the port is the - one thing the person is looking at, and the framework name without the - project it belongs to is no use on a machine running four of them. The - pid is the first to go, then the kind. - """ - # An orphan's kind is the words "nothing listening", which reads badly - # in the middle of a sentence about it. The port is the whole subject. - if row.get("orphan"): - return ":%d" % row["port"] - what = row["kind"] or "unidentified" - where = row["project"] - # Not every row has a pid. A port Tailscale serves with nothing behind - # it has none by definition, and one that exits while its screen is open - # loses the one it had - both can still be the subject of a prompt. - who = " (pid %d)" % row["pid"] if row.get("pid") else "" - full = "%s%s on :%d%s" % (what, " in " + where if where else "", - row["port"], who) - for text in (full, - "%s%s on :%d" % (what, " in " + where if where else "", - row["port"]), - "%s on :%d" % (where or what, row["port"]), - ":%d" % row["port"]): - if len(text) <= room: - return text - return ":%d" % row["port"] - - -def prompt(ask, key, options, w): - """A question and the key that answers it, fitted to the pane. - - On one line where both fit, on two where they do not, because the key is - never the half that may be truncated: a prompt with its `[y]` pushed off - the right edge is a prompt nobody can act on. The wording after the key - has shorter forms for the same reason, longest first. - """ - def answer(room): - text = next((o for o in options if len(key[1]) + len(o) <= room), "") - return [key, (DIM, text)] - - # The bare last form - "[y] yes", with no word on what anything else - # does - is a fallback for a pane too narrow for two lines, not a thing - # to choose while a second line is going spare. - keep = options[-2] if len(options) > 1 else options[-1] - room = (w - 1) - sum(len(t) for _, t in ask) - 2 - if room >= len(key[1]) + len(keep): - return [seg(ask + [(DIM, " ")] + answer(room), w - 1)] - return [seg(ask, w - 1), seg([(DIM, " ")] + answer(w - 2), w - 1)] - - -def bind_note(row): - """What the bound address means for who can reach it. - - The address itself is rarely the answer to that question and is always - too wide for the column - a tailnet IPv6 address is 24 characters and - pushed every field after it off the line. What matters is the class: - every interface, loopback only, or bound to one particular network. - """ - if row.get("orphan"): - return "--", DIM - reach = bind_class(row["bind"]) - if reach == "all": - return "all", OPEN - if reach == "local": - return "local", LOCAL - if reach == "tailnet": - return "tailnet", ACCENT - return reach[:7], TXT - - -ACTIONS = { - "serve": ("publish", "tailnet only"), - "funnel": ("publish publicly", "anyone with the URL"), - "unserve": ("stop serving", ""), - "unfunnel": ("stop the funnel", ""), - "tunnel": ("open a cloudflare tunnel", "anyone with the URL"), - "untunnel": ("close the cloudflare tunnel", ""), -} - - -def do_work(kind, row, done): - """Carry out one exposure change and record how it went.""" - port = row["port"] - if kind in ("serve", "funnel"): - failed = serve_port(port, kind == "funnel") - done.append((failed, BAD, time.time() + 8) if failed - else ("%s now serves :%d" % (kind, port), OK, - time.time() + 6)) - elif kind in ("unserve", "unfunnel"): - failed = unserve_port(port, kind == "unfunnel") - done.append((failed, BAD, time.time() + 8) if failed - else ("stopped serving :%d" % port, OK, time.time() + 5)) - elif kind == "tunnel": - url, failed = start_tunnel(port) - done.append((failed, BAD, time.time() + 10) if failed - else (url, OK, time.time() + 20)) - elif kind == "untunnel": - note = tunnel_state(port) - if note: - end(note["pid"], signal.SIGTERM) - forget_tunnel(port) - done.append(("closed the tunnel on :%d" % port, OK, time.time() + 5)) - - -def start_work(kind, row): - """Run an exposure change on a thread, so the frame keeps drawing.""" - work = {"kind": kind, "row": row, "done": []} - threading.Thread(target=do_work, args=(kind, row, work["done"]), - daemon=True).start() - return work - - -def footer(confirm, watch, working, notice, w, hints): - """The bottom of either screen. - - One of five things, in the order they matter: a question that must be - answered before anything happens, the wait after answering it, a slow - action still running, the outcome of the last one, or the ordinary keys. - """ - if confirm is not None: - verb, cost = ACTIONS.get(confirm["kind"], ("kill", "")) - ask = [(BAD, " " + verb + " "), - (TXT, kill_label(confirm["row"], w - 34 - len(verb)))] - if cost: - ask.append((WARN, " - " + cost)) - return prompt(ask + [(DIM, "?")], (WARN, "[y]"), - [" yes · any other key cancels", - " yes · any key cancels", " yes"], w) - if watch is not None and watch["asked"]: - return prompt([(WARN, " still up: "), - (TXT, kill_label(watch["row"], w - 30))], (BAD, "[f]"), - [" force kill · any other key leaves it", - " SIGKILL · any key leaves it", " SIGKILL"], w) - if watch is not None: - return [seg([(DIM, " SIGTERM sent, waiting for "), - (TXT, kill_label(watch["row"], w - 29))], w - 1)] - if working is not None: - verb = ACTIONS.get(working["kind"], ("working", ""))[0] - return [seg([(WARN, " %s :%d - this can take a moment" - % (verb, working["row"]["port"]))], w - 1)] - if notice is not None: - return [seg([(notice[1], " " + notice[0])], w - 1)] - return [" " + line for line in pack_hints(hints, w - 2)] - - -def has_detail(row): - """Whether there is anything behind this row worth a second screen. - - A process of ours carries a command line, a directory and an age that - the table has no room for, and any row at all can be given an address to - copy or an exposure to set up. Another user's socket carries none of - that: the four columns already say everything /proc will tell us, and - opening a screen to repeat them would be a screen that wastes a press. - """ - return bool(row.get("pid") or row.get("orphan") or row.get("exposed")) - - -def wrap(text, width): - """Break a long value across lines at spaces, then anywhere.""" - lines, rest = [], text - while rest and len(lines) < 4: - if len(rest) <= width: - lines.append(rest) - break - cut = rest.rfind(" ", 0, width + 1) - cut = cut if cut > width // 2 else width - lines.append(rest[:cut]) - rest = rest[cut:].lstrip() - return lines or [""] - - -def field(label, value, w, colour=TXT, label_w=10): - """One `label value` line, wrapped under its own label.""" - rows = [] - for i, line in enumerate(wrap(value, max(8, (w - 3) - label_w))): - rows.append(seg([(DIM, " " + pad(label if not i else "", label_w)), - (colour, line)], w - 1)) - return rows - - -def expose_options(row, net, tunnel): - """The ways this port could be published, and why one is unavailable. - - Each is a key, a name, and the state that decides whether pressing it - does anything. An option that cannot work says so on the line rather - than failing after the keypress - except Funnel, which is offered even - when the capability is missing, because Tailscale's own error names the - setting to change in the admin console better than this can. - """ - how = row.get("exposed") - # One blocker outranks the others: without the operator bit every serve - # and funnel write is refused, whatever else is true of them. - barred = "" if net.get("operator") else "needs: tailscale set --operator" - return [ - ("s", "tailscale serve", - "serving · tailnet only" if how == "tailnet" - else barred or "tailnet only", how == "tailnet"), - ("t", "tailscale funnel", - "public · anyone with the URL" if how == "public" - else barred or ("public" if net["funnel"] - else "not enabled for this node"), - how == "public"), - ("d", "cloudflare tunnel", - "running · %s" % tunnel["url"] if tunnel - else "quick tunnel, random domain" if have("cloudflared") - else "cloudflared not installed", - bool(tunnel)), - ] - - -def detail_rows(row, net, cfg, tunnel, links, sel, w): - """The second screen: everything known about one port, and what to do.""" - rows = [title(":%d" % row["port"], w, PORT)] - head = row["kind"] or ("%s's" % row["user"] if row.get("user") else "") - if row["project"]: - head += " in " + row["project"] - rows.append(seg([(TXT, " " + head.strip()), - (DIM, " · up " + span(row["up"]) if row["up"] - else "")], w - 1)) - rows.append("") - - if row.get("pid"): - rows.append(seg([(LBL, " ── PROCESS ── ")], w - 1)) - rows += field("command", row["cmdline"] or "?", w) - rows += field("directory", row["cwd"] or "?", w, - WARN if row["gone"] else TXT) - group = "" - try: - group = " · group %d" % os.getpgid(row["pid"]) - except OSError: - pass - rows += field("pid", "%d%s" % (row["pid"], group), w) - rows.append("") - - # A lone :: is not an IPv6-only server: Linux maps IPv4 onto it unless - # the process asked for IPV6_V6ONLY, and /proc cannot say which it did. - # Claiming "IPv6 only" here would be a guess dressed as a fact. - note = ("two sockets, IPv4 and IPv6" if row.get("families", 0) > 1 - else "IPv4 too, unless the server turned that off" - if row["bind"] == "::" else "one socket") - rows.append(seg([(LBL, " ── LISTENING ON ── "), - (TXT, row["bind"] or "nothing"), - (DIM, " " + note)], w - 1)) - rows.append("") - rows.append(seg([(LBL, " ── REACHABLE AT ── "), - (DIM, "↑↓ to pick, c copies")], w - 1)) - if not links: - rows.append(seg([(DIM, " nothing is listening to reach")], w - 1)) - for i, (url, note) in enumerate(links): - here = i == sel - rows.append(seg([(ACCENT if here else DIM, " ▸ " if here else " "), - (TXT if here else DIM, url), (DIM, " " + note)], - w - 1)) - rows.append("") - rows.append(seg([(LBL, " ── EXPOSE ── ")], w - 1)) - for key, name, state, on in expose_options(row, net, tunnel): - rows.append(seg([(ACCENT, " [%s] " % key), (TXT, pad(name, 18)), - (OK if on else DIM, state)], w - 1)) - return rows - - -def main(): - maybe_help(__doc__) - global REFRESH - args = sys.argv[1:] - while args and args[0] in ("-n", "--refresh"): - REFRESH = max(1.0, float(args[1])) - args = args[2:] - - setup() - keyboard = Keyboard() - store = Store() - threading.Thread(target=store.run, daemon=True).start() - selected, hide_system, scroll = 0, True, 0 - confirm = None # an action awaiting an explicit yes before it happens - watch = None # what SIGTERM was sent to, and whether it has died - notice = None # (text, colour, expires) - the result of the last one - detail = None # the port whose second screen is open, and its state - working = None # an action too slow to block the frame on - net = cfg = None # tailnet identity and serve config, read on demand - - while True: - w, h = size() - all_rows, fetched, err = store.snapshot() - shown = [r for r in all_rows if not (hide_system and theirs(r))] - selected = max(0, min(selected, len(shown) - 1)) if shown else 0 - - # A process that dies on its own between the prompt and the deadline - # is the normal case, and wants no further questions. - if watch is not None and not watch["asked"]: - if not alive(watch["pid"]): - notice = ("stopped " + kill_label(watch["row"], w - 11), - OK, time.time() + 5) - watch = None - store.wake.set() - elif time.time() >= watch["deadline"]: - watch["asked"] = True - - # An action that talks to tailscaled or cloudflared takes seconds, - # which is far too long to hold a frame for, so it runs on a thread - # and its answer is collected here. - if working is not None and working["done"]: - notice = working["done"][0] - working = None - net = cfg = None - store.wake.set() - - for key in keyboard.poll(): - if confirm is not None: - # Only an explicit yes acts. Every other key cancels, - # deliberately including q: quitting must never double as - # consent to signal something or publish it. - act, confirm = confirm, None - if key not in ("y", "Y"): - notice = ("cancelled", DIM, time.time() + 2) - continue - row, kind = act["row"], act["kind"] - if kind == "kill": - pid, why = killable(row) - if why: - notice = (why, BAD, time.time() + 5) - continue - failed = end(pid, signal.SIGTERM) - if failed: - notice = ("%s: %s" - % (kill_label(row, w - 4 - len(failed)), - failed), BAD, time.time() + 5) - store.wake.set() - else: - watch = {"pid": pid, "row": row, "asked": False, - "deadline": time.time() + 3.0} - else: - working = start_work(kind, row) - continue - if watch is not None and watch["asked"]: - pid, row, watch = watch["pid"], watch["row"], None - if key in ("f", "F"): - failed = end(pid, signal.SIGKILL) - notice = ("%s: %s" % (kill_label(row, w - 4 - len(failed)), - failed) if failed - else "SIGKILL sent to " - + kill_label(row, w - 19), - BAD if failed else WARN, time.time() + 5) - else: - notice = ("left running: " + kill_label(row, w - 17), - DIM, time.time() + 3) - store.wake.set() - continue - if detail is not None: - # The second screen keeps its own selection - of addresses - # rather than rows - and hands every other key back. - if key in ("esc", "left", "q", "Q", "backspace"): - detail = None - continue - if key == "up": - detail["at"] -= 1 - elif key == "down": - detail["at"] += 1 - elif key in ("c", "C"): - links = detail["links"] - if links: - url = links[max(0, min(detail["at"], - len(links) - 1))][0] - # The address goes in the notice either way: OSC 52 - # is refused by some terminals and swallowed by some - # multiplexers, and a copy that silently did nothing - # would leave nothing on screen to read instead. - ok = clipboard(url) - notice = (("copied " if ok else "no clipboard ") - + url, OK if ok else WARN, time.time() + 8) - elif key in ("s", "S", "t", "T", "d", "D"): - kind = {"s": "serve", "t": "funnel", - "d": "tunnel"}[key.lower()] - how = detail["row"].get("exposed") - if kind == "serve" and how == "tailnet": - kind = "unserve" - elif kind == "funnel" and how == "public": - kind = "unfunnel" - elif kind == "tunnel" and detail["tunnel"]: - kind = "untunnel" - elif kind == "tunnel" and not have("cloudflared"): - notice = ("cloudflared is not installed - see " - "the docs for the one-line install", - WARN, time.time() + 8) - continue - if working is None: - confirm = {"kind": kind, "row": detail["row"]} - elif key == "r": - net = cfg = None - store.wake.set() - continue - if key in ("q", "Q"): - raise SystemExit(0) - if key == "up": - selected -= 1 - elif key == "down": - selected += 1 - elif key == "o": - hide_system = not hide_system - elif key == "r": - store.wake.set() - elif key in ("enter", "right", "i") and shown: - row = shown[max(0, min(selected, len(shown) - 1))] - if has_detail(row): - detail = {"port": row["port"], "row": row, "at": 0, - "links": [], "tunnel": None} - net = cfg = None - else: - notice = ("nothing more to show - /proc will not name " - "another user's process", DIM, time.time() + 5) - elif key in ("k", "K") and shown and watch is None: - row = shown[max(0, min(selected, len(shown) - 1))] - pid, why = killable(row) - if why: - notice = (why, BAD, time.time() + 5) - else: - confirm = {"kind": "kill", "row": row} - - # Rebuilt after the keys rather than before them, so that a press of - # o is answered in the frame it was made in and not the next one. - shown = [r for r in all_rows if not (hide_system and theirs(r))] - selected = max(0, min(selected, len(shown) - 1)) if shown else 0 - if notice and time.time() >= notice[2]: - notice = None - - if detail is not None: - # Tailscale is asked once per visit rather than once per frame: - # two subprocesses at 3Hz would cost more than the whole rest of - # the widget. Any change made here clears them. - if net is None: - net, cfg = tailnet_self(), serve_config() - live = next((r for r in all_rows if r["port"] == detail["port"]), - None) - detail["row"] = live or dict(detail["row"], pid=None, gone=True) - detail["tunnel"] = tunnel_state(detail["port"]) - detail["links"] = addresses(detail["row"], net, cfg) - if detail["tunnel"]: - detail["links"] = detail["links"] + [ - (detail["tunnel"]["url"], "public · cloudflare")] - detail["at"] = max(0, min(detail["at"], - len(detail["links"]) - 1)) - rows = detail_rows(detail["row"], net, cfg, detail["tunnel"], - detail["links"], detail["at"], w) - foot = footer(confirm, watch, working, notice, w, - [[(ACCENT, "↑↓"), (DIM, " address")], - [(DIM, "[c]opy")], [(DIM, "[s]erve")], - [(DIM, "[t]unnel")], [(DIM, "[d] cloudflare")], - [(ACCENT, "esc"), (DIM, " back")]]) - while len(rows) < h - len(foot) - 1: - rows.append("") - draw(rows[:h - len(foot) - 1] + foot, w, h) - time.sleep(0.3) - continue - - mine = sum(1 for r in all_rows if r["pid"]) - out = sum(1 for r in all_rows if r.get("exposed")) - - rows = [title("dev servers", w, PORT)] - rows.append(seg([(DIM, " %d listening" % len(all_rows)), - (DIM, " · %d yours" % mine), - (DIM, " · "), - (OK if out else DIM, "%d reachable off-box" % out), - (DIM, " every %gs" % REFRESH)], w - 1)) - if err: - rows.append(seg([(BAD, " ! " + err)], w - 1)) - rows.append("") - - wide = w >= 78 - # The project column takes whatever the fixed ones leave, because it - # is the one that identifies the server and the one whose contents - # are a directory name of any length. - fixed = 1 + 6 + 8 + 18 + (6 + 8 if wide else 0) - name_w = max(8, (w - 1) - fixed) - rows.append(seg([(DIM, " PORT BIND WHAT "), - (DIM, pad("PROJECT", name_w)), - (DIM, "UP EXPOSED" if wide else "")], w - 1)) - visible = max(1, h - len(rows) - 3) - if selected < scroll: - scroll = selected - elif selected >= scroll + visible: - scroll = selected - visible + 1 - scroll = max(0, min(scroll, max(0, len(shown) - visible))) - - for i in range(scroll, min(len(shown), scroll + visible)): - row = shown[i] - here = i == selected - tint = bg(28, 44, 62) if here else "" - note, note_colour = bind_note(row) - # Another user's row names its owner rather than its project, - # which it has none of that we can read. That is the whole of - # what is knowable about it, and it is more use than the - # "(not ours)" this used to say in the column beside it. - who = row["project"] or row.get("user") or ("—" if row["pid"] - else "") - line = [(tint + (ACCENT if here else PORT), - ("▸" if here else " ") + "%-6d" % row["port"]), - (tint + note_colour, "%-8s" % note), - (tint + (DIM if row["guessed"] or not row["kind"] else TXT), - pad(row["kind"], 18)), - (tint + (WARN if row["gone"] else - DIM if row.get("user") else TXT), - pad(who, name_w))] - if wide: - line.append((tint + DIM, "%-6s" % span(row["up"]))) - line.append((tint + (OK if row["exposed"] == "tailnet" - else BAD if row["exposed"] == "public" - else GRID), - row["exposed"] or "-")) - if here: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - - foot = footer(confirm, watch, working, notice, w, - [[(ACCENT, "↑↓"), (DIM, " select")], - [(ACCENT, "↵"), (DIM, " details")], [(DIM, "[k]ill")], - [(DIM, "[o]%s system" - % ("show" if hide_system else "hide"))], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]]) - - while len(rows) < h - len(foot) - 1: - rows.append("") - rows.extend(foot) - draw(rows, w, h) - time.sleep(0.3) - - -main() diff --git a/pr.py b/pr.py deleted file mode 100755 index a539fb7..0000000 --- a/pr.py +++ /dev/null @@ -1,1073 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Watch the pull requests you have to follow up on. - -A list of open PRs, and a dashboard for whichever one is selected: checks, -reviews, mergeability, and - when the PR belongs to a stack - the stack it -sits in and the order that stack has to merge in. - - python3 pr.py [-n SECONDS] [search terms ...] - -Extra arguments are appended to the search, so `pr.py org:acme` narrows to one -organisation and `pr.py author:@me` to your own. With none given it uses -`pr.query` from config, which defaults to everything you are involved in. - -Credentials: reuses `github.token` from config.json, or $GITHUB_TOKEN. A -classic token with `repo` and `read:org`, the same one github.py uses. - -Keys: up/down select, enter opens a PR, esc goes back, / filters, s cycles the -sort, o reverses it, r refreshes, q quits. -""" -import datetime -import json -import os -import sys -import threading -import time -import urllib.error -import urllib.request - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (RST, Keyboard, bg, clipboard, config_token_warning, cycle, - draw, heat, - load_config, maybe_help, pack_hints, pad, rgb, seg, setup, - size, skeleton, stacked_bar, title, vbars) - -_GH = load_config("github", {"token": "", "token_env": "GITHUB_TOKEN"}) -_CFG = load_config("pr", { - "token": "", - "token_env": "GITHUB_TOKEN", - # GitHub search has no OR, so anything that is a union of conditions has - # to be several searches merged. Each entry here is one search; results - # are pooled and de-duplicated. `@mine` expands to every org you belong - # to plus your own account, as owner qualifiers. - "sources": { - "orgs": "is:open is:pr @mine", - "authored": "is:open is:pr author:@me", - "assigned": "is:open is:pr assignee:@me", - }, - "limit": 50, # per source; 3 x 100 nodes returns HTTP 502 - "refresh": 60, -}) - -REFRESH = float(_CFG["refresh"]) -API = "https://api.github.com/graphql" -SORTS = ("updated", "created") -SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" - -SPARK = "▁▂▃▄▅▆▇█" -OPENED_DAYS = 30 # width of the opened-per-day chart - -OK = rgb(90, 240, 160) -WARN = rgb(255, 200, 90) -BAD = rgb(255, 100, 110) -DIM = rgb(127, 147, 172) -GRID = rgb(60, 78, 98) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) -PR = rgb(180, 160, 255) - -# GitHub's vocabulary, rendered as words rather than colour alone so it -# survives a screenshot, a colourblind reader and a `pane read` -REVIEW_LABEL = {"APPROVED": ("approved", OK), - "CHANGES_REQUESTED": ("CHANGES REQ", BAD), - "REVIEW_REQUIRED": ("needs review", WARN), - None: ("—", DIM)} -CHECK_LABEL = {"SUCCESS": ("pass", OK), "FAILURE": ("FAIL", BAD), - "ERROR": ("ERROR", BAD), "PENDING": ("running", WARN), - "EXPECTED": ("waiting", DIM), None: ("—", DIM)} -MERGE_LABEL = {"CLEAN": ("ready", OK), "DIRTY": ("CONFLICT", BAD), - "BLOCKED": ("blocked", WARN), "BEHIND": ("behind", WARN), - "UNSTABLE": ("checks red", WARN), "HAS_HOOKS": ("ready", OK), - "DRAFT": ("draft", DIM), "UNKNOWN": ("…", DIM), - None: ("—", DIM)} - - -def token(): - """The GitHub token, shared with github.py rather than duplicated.""" - for value, src in ((_CFG["token"], "config"), (_GH["token"], "config")): - if value: - return value, src - tok = os.environ.get(_CFG["token_env"] or "GITHUB_TOKEN") - return (tok, "env") if tok else (None, "missing") - - -_RATE = {"remaining": None} - - -def graphql(query, tok, variables=None): - body = json.dumps({"query": query, "variables": variables or {}}).encode() - req = urllib.request.Request(API, data=body, headers={ - "Authorization": "Bearer " + tok, - "Content-Type": "application/json", - "User-Agent": "terminal-toys", - }) - with urllib.request.urlopen(req, timeout=45) as r: - data = json.load(r) - if data.get("errors"): - raise ValueError(data["errors"][0].get("message", "")[:100]) - return data["data"] - - -PR_FIELDS = """ - number title url isDraft createdAt updatedAt - additions deletions changedFiles - author { login } - repository { nameWithOwner } - headRefName baseRefName reviewDecision mergeable - stackEntry { position stack { number size } } - commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }""" - - -def list_query(queries, limit): - """One request, one aliased search per source. - - The ceiling is on result nodes rather than field complexity: three - searches of 100 return HTTP 502 with or without the check rollup, three - of 50 do not. So the page size is per source and deliberately modest. - """ - parts = ["rateLimit { remaining }"] - for i, q in enumerate(queries): - parts.append('s%d: search(query: %s, type: ISSUE, first: %d) ' - '{ issueCount nodes { ... on PullRequest { %s } } }' - % (i, json.dumps(q), limit, PR_FIELDS)) - return "{ %s }" % " ".join(parts) - -DETAIL_QUERY = """ -query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - number title url state isDraft createdAt updatedAt - additions deletions changedFiles - author { login } headRefName baseRefName - mergeable mergeStateStatus reviewDecision - commitCount: commits { totalCount } - stack { number size baseRefName - entries(first: 40) { nodes { position pullRequest { - number title isDraft reviewDecision mergeable - additions deletions author { login } headRefName } } } } - reviewThreads(first: 60) { nodes { isResolved } } - reviews(last: 20) { nodes { author { login } state submittedAt } } - reviewRequests(first: 12) { nodes { requestedReviewer { - ... on User { login } ... on Team { name } } } } - commits(last: 1) { nodes { commit { statusCheckRollup { - state contexts(first: 25) { nodes { - ... on CheckRun { name conclusion status startedAt completedAt } - ... on StatusContext { context state } } } } } } } - } - } -}""" - -# every open PR in one repository, for reconstructing a stack that was not -# made with `gh stack` - the API's own stack field is authoritative when it -# is there, and null everywhere else -REPO_PRS_QUERY = """ -query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - pullRequests(states: OPEN, first: 100) { - nodes { number title isDraft headRefName baseRefName - additions deletions author { login } - reviewDecision mergeable } - } - } -}""" - - -def parse(ts): - if not ts: - return None - try: - return datetime.datetime.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S").replace( - tzinfo=datetime.timezone.utc) - except ValueError: - return None - - -def ago(ts): - when = parse(ts) if isinstance(ts, str) else ts - if not when: - return "--" - s = (datetime.datetime.now(datetime.timezone.utc) - when).total_seconds() - if s < 3600: - return "%dm" % max(1, int(s // 60)) - if s < 86400: - return "%dh" % int(s // 3600) - if s < 86400 * 365: - return "%dd" % int(s // 86400) - return "%.1fy" % (s / (86400 * 365.0)) - - -def rollup(pr): - node = (pr.get("commits") or {}).get("nodes") or [] - if not node: - return None - return ((node[0].get("commit") or {}).get("statusCheckRollup") or {}).get( - "state") - - -def stack_of(pr, repo_prs): - """The chain this PR belongs to, newest-first from the trunk. - - GitHub's own `stack` is used when present. Otherwise the chain is - reconstructed: a PR whose base branch is another open PR's head branch is - sitting on top of it. That inference produces a tree rather than a line, - so each PR keeps its list of children. - """ - by_head = {} - for other in repo_prs: - by_head[other["headRefName"]] = other - parent = {} - kids = {} - for other in repo_prs: - up = by_head.get(other["baseRefName"]) - if up and up["number"] != other["number"]: - parent[other["number"]] = up["number"] - kids.setdefault(up["number"], []).append(other["number"]) - if pr["number"] not in parent and pr["number"] not in kids: - return None, {}, {} - root = pr["number"] - seen = set() - while root in parent and root not in seen: - seen.add(root) - root = parent[root] - return root, parent, kids - - -class Store(object): - def __init__(self, extra): - self.lock = threading.Lock() - self.extra = extra - self.viewer = None - self.orgs = [] - self.query = "" - self.prs = [] - self.total = 0 - self.capped = [] - self.target = "" - self.detail = None # the open PR's full record - self.stack_rows = [] # (depth, pr, is_current) for the stack map - self.want = None # (owner, name, number) to fetch detail for - self.loading_detail = False - self.stages = [] # what the open is actually doing, live - self.error = None - self.fetched = 0 - self.wake = threading.Event() - - def snapshot(self): - with self.lock: - return (list(self.prs), self.total, self.detail, - list(self.stack_rows), self.loading_detail, self.error, - self.fetched, self.query, list(self.stages)) - - def searches(self): - """Each configured source, with `@mine` expanded and args appended. - - Repeated qualifiers of the same kind are OR'd by GitHub, so one - search covers every org and your own account at once; relationships - that reach outside them - authored, assigned - need their own. - """ - mine = " ".join(["org:%s" % o for o in self.orgs] - + (["user:%s" % self.viewer] if self.viewer else [])) - out = [] - for name, q in _CFG["sources"].items(): - out.append((name, " ".join([q.replace("@mine", mine)] - + self.extra))) - return out - - def open_detail(self, pr): - with self.lock: - owner, _, name = pr["repository"]["nameWithOwner"].partition("/") - self.want = (owner, name, pr["number"]) - self.detail, self.stack_rows = None, [] - self.loading_detail = True - self.target = "%s/%s #%d" % (owner, name, pr["number"]) - self.stages = [] - self.wake.set() - - def close_detail(self): - with self.lock: - self.want, self.detail, self.stack_rows = None, None, [] - self.loading_detail = False - self.stages = [] - - def run(self): - # A daemon thread that raises just stops, and a dead poller looks - # exactly like a source with no data - which is how deployments.py - # showed "0 deploys" for a day after an import went missing. - try: - self.poll() - except Exception as e: - with self.lock: - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:70]) - - def poll(self): - while True: - tok, source = token() - if not tok: - with self.lock: - self.error = ("no token: set github.token in config.json " - "or $GITHUB_TOKEN") - self.wake.wait(REFRESH) - self.wake.clear() - continue - try: - with self.lock: - want = self.want - have = (self.detail or {}).get("number") - if want and want[2] != have: - self.fetch_detail(tok, want) - self.fetch_list(tok, source) - except urllib.error.HTTPError as e: - with self.lock: - self.error = "HTTP %s from GitHub%s" % ( - e.code, " (token scope?)" if e.code == 403 else "") - self.loading_detail = False - except Exception as e: - with self.lock: - self.error = "%s: %s" % (type(e).__name__, str(e)[:70]) - self.loading_detail = False - self.wake.wait(REFRESH) - self.wake.clear() - - def fetch_list(self, tok, source): - if self.viewer is None: - who = graphql("{ viewer { login organizations(first:20)" - " { nodes { login } } } }", tok)["viewer"] - with self.lock: - self.viewer = who["login"] - self.orgs = [o["login"] for o in who["organizations"]["nodes"]] - pairs = self.searches() - d = graphql(list_query([q for _n, q in pairs], int(_CFG["limit"])), tok) - rate = d.get("rateLimit") or {} - if rate.get("remaining") is not None: - _RATE["remaining"] = rate["remaining"] - # pool the sources, remembering which found each PR and noting when a - # source filled its page, so a truncated union is not read as a total - pool, capped = {}, [] - for i, (name, _q) in enumerate(pairs): - block = d["s%d" % i] - got = [n for n in block["nodes"] if n] - if block["issueCount"] > len(got): - capped.append(name) - for n in got: - pool.setdefault(n["url"], dict(n, sources=[]))["sources"].append(name) - nodes = list(pool.values()) - with self.lock: - self.query = ", ".join(n for n, _q in pairs) - self.capped = capped - self.prs = nodes - self.total = len(nodes) - self.fetched = time.time() - self.error = (config_token_warning() if source == "config" else None) - - def stage(self, label, state, started=None): - """Record what the open is doing, so the wait can show real work.""" - with self.lock: - for st in self.stages: - if st["label"] == label: - st["state"] = state - st["took"] = time.time() - (started or st["t0"]) - return st["t0"] - self.stages.append({"label": label, "state": state, - "t0": time.time(), "took": 0.0}) - return self.stages[-1]["t0"] - - def fetch_detail(self, tok, want): - owner, name, number = want - t0 = self.stage("pull request, checks, reviews", "running") - d = graphql(DETAIL_QUERY, tok, {"owner": owner, "name": name, - "number": number}) - self.stage("pull request, checks, reviews", "done", t0) - pr = (d.get("repository") or {}).get("pullRequest") - rows = [] - if pr: - native = pr.get("stack") - if native: - self.stage("stack, from GitHub", "done") - # GitHub hands the order over directly, position 1 nearest the - # base. A native stack is a line, not a tree, so it draws flat - # - eleven levels of indentation would be unreadable and would - # imply a branching that is not there. - entries = sorted(native["entries"]["nodes"], - key=lambda x: x.get("position") or 0) - for i, e in enumerate(entries): - child = e["pullRequest"] - twig = "└─ " if i == len(entries) - 1 else "├─ " - rows.append((twig, child, child["number"] == number, - e.get("position"))) - else: - t1 = self.stage("stack, from open branches", "running") - repo = graphql(REPO_PRS_QUERY, tok, - {"owner": owner, "name": name}) - self.stage("stack, from open branches", "done", t1) - others = (repo.get("repository") or {}).get( - "pullRequests", {}).get("nodes", []) - root, parent, kids = stack_of(pr, others) - if root is not None: - by_num = dict((o["number"], o) for o in others) - - def walk(num, prefix, last): - # an inferred stack really is a tree - one PR here has - # two others branched off it - so draw the connectors - # properly rather than indenting by depth alone - node = by_num.get(num) - if node: - rows.append((prefix + ("└─ " if last else "├─ "), - node, num == number, None)) - children = sorted(kids.get(num, [])) - below = prefix + (" " if last else "│ ") - for i, kid in enumerate(children): - walk(kid, below, i == len(children) - 1) - - walk(root, "", True) - with self.lock: - self.detail = pr - self.stack_rows = rows - self.loading_detail = False - - -def hours_since(ts): - when = parse(ts) - if not when: - return None - return (datetime.datetime.now(datetime.timezone.utc) - - when).total_seconds() / 3600.0 - - -def span(hours): - if hours is None: - return "--" - if hours < 48: - return "%dh" % int(hours) - days = hours / 24.0 - return "%dd" % int(days) if days < 365 else "%.1fy" % (days / 365.0) - - -def ready_to_merge(pr): - """Approved, green, no conflict, not a draft - the actionable count. - - Everything else on this board describes work in flight; this is the one - number that says something can be done right now. - """ - return (pr.get("reviewDecision") == "APPROVED" - and rollup(pr) in ("SUCCESS", None) - and pr.get("mergeable") != "CONFLICTING" - and not pr.get("isDraft")) - - -def stats_view(prs, w): - """Shape and age of every open PR, whatever the list is filtered to. - - Deliberately not the filtered set. Typing in the filter is a search, and - a search should not move the backlog it is searching: watching the age - median and the state bar lurch on every keystroke made them unreadable - and, worse, made them look like statements about the whole board when - they described three matching rows. - - The list below says how many of how many it is showing; these say what - the board is. - """ - rows = [""] - if not prs: - return rows - n = len(prs) - review = {"APPROVED": 0, "CHANGES_REQUESTED": 0, "REVIEW_REQUIRED": 0, - None: 0} - checks = {"SUCCESS": 0, "FAILURE": 0, "PENDING": 0, "other": 0} - drafts = conflicts = ready = 0 - for pr in prs: - review[pr.get("reviewDecision") if pr.get("reviewDecision") in review - else None] += 1 - state = rollup(pr) - checks[state if state in checks else "other"] += 1 - drafts += 1 if pr.get("isDraft") else 0 - conflicts += 1 if pr.get("mergeable") == "CONFLICTING" else 0 - ready += 1 if ready_to_merge(pr) else 0 - - rows.append(seg([(LBL, " ── STATE ── "), (TXT, "%d" % n), - (DIM, " open · "), (DIM, "%d draft" % drafts), - (DIM, " · "), - (BAD if conflicts else DIM, "%d conflicting" % conflicts), - (DIM, " · "), - (OK if ready else DIM, "%d ready to merge" % ready)], - w - 1)) - order = [("APPROVED", OK), ("CHANGES_REQUESTED", BAD), - ("REVIEW_REQUIRED", WARN), (None, DIM)] - parts = [(review[k] / float(n), c) for k, c in order if review[k]] - rows.append(seg([(RST, " ")] + stacked_bar(parts, max(10, w - 3)), w - 1)) - key = [(RST, " ")] - for k, colour in order: - if review[k]: - key += [(colour, "▇ "), (TXT, REVIEW_LABEL[k][0]), - (DIM, " %d " % review[k])] - for k, colour, label in (("SUCCESS", OK, "checks pass"), - ("FAILURE", BAD, "checks FAIL"), - ("PENDING", WARN, "running")): - if checks[k]: - key += [(colour, "· "), (TXT, label), (DIM, " %d " % checks[k])] - rows.append(seg(key, w - 1)) - - ages = sorted((hours_since(p.get("createdAt")), p) for p in prs - if hours_since(p.get("createdAt")) is not None) - idles = [(hours_since(p.get("updatedAt")), p) for p in prs - if hours_since(p.get("updatedAt")) is not None] - - # ── when the open ones arrived ────────────────────────────────────── - today = datetime.date.today() - days = [(today - datetime.timedelta(days=k)).isoformat() - for k in range(OPENED_DAYS - 1, -1, -1)] - per_day = dict((d, 0) for d in days) - inside = 0 - for pr in prs: - key = (pr.get("createdAt") or "")[:10] - if key in per_day: - per_day[key] += 1 - inside += 1 - avail = max(10, w - 3) - slot = max(1, avail // len(days)) - gap = 1 if slot >= 3 else 0 - barw = slot - gap - cols = [] - for i, d in enumerate(days): - cols.extend([(per_day[d], PR)] * barw) - if gap and i < len(days) - 1: - cols.extend([(0, PR)] * gap) - peak = max(per_day.values()) if per_day else 0 - rows.append("") - rows.append(seg([(LBL, " ── OPENED / DAY ── "), - (DIM, "last %dd · " % OPENED_DAYS), - (TXT, "%d" % inside), (DIM, " of %d still open · " % n), - (DIM, "peak %d/day" % peak)], w - 1)) - if peak: - for line in vbars(cols, 3): - rows.append(seg([(RST, " ")] + line, w - 1)) - rows.append(seg([(RST, " "), (GRID, "─" * len(cols))], w - 1)) - left = "%dd ago" % OPENED_DAYS - rows.append(seg([(DIM, " " + left), - (DIM, " " * max(1, len(cols) - len(left) - 5)), - (DIM, "today")], w - 1)) - else: - rows.append(seg([(DIM, " none of the open PRs were opened in the " - "last %dd" % OPENED_DAYS)], w - 1)) - - # ── how old they are ──────────────────────────────────────────────── - def at(pairs, frac): - if not pairs: - return None - vals = sorted(x[0] for x in pairs) - return vals[min(len(vals) - 1, int(len(vals) * frac))] - - def worst(pairs, colour): - if not pairs: - return ("--", DIM) - hours, pr = max(pairs, key=lambda x: x[0]) - return ("#%d %s" % (pr["number"], span(hours)), colour) - - rows.append("") - rows.append(seg([(LBL, " ── AGE ── "), - (DIM, "median "), (TXT, span(at(ages, 0.5))), - (DIM, " p95 "), (TXT, span(at(ages, 0.95))), - (DIM, " max "), (WARN, span(at(ages, 1.0))), - (DIM, " idle median "), (TXT, span(at(idles, 0.5)))], - w - 1)) - if ages: - # One bar per open PR, youngest left to oldest right - the x axis is - # rank, not time. It fills the pane and carries a baseline and end - # labels, because a sparkline that stops in the middle of the screen - # gives no way to tell where the chart ends and the blank begins. - room = max(10, w - 3) - drawn = ages[-room:] if len(ages) > room else ages - # spread the remainder across the leftmost bars so the chart reaches - # the right edge exactly: stopping short of it left no way to tell a - # finished chart from a truncated one - if len(drawn) >= room: - slot, extra = 1, 0 - else: - slot, extra = divmod(room, len(drawn)) - hi = max(x[0] for x in drawn) or 1 - bars = [] - for i, (hours, _pr) in enumerate(drawn): - wide_bar = slot + (1 if i < extra else 0) - bars.extend([SPARK[min(7, int(hours / hi * 7.99))]] * wide_bar) - rows.append(seg([(RST, " "), (heat(0.4), "".join(bars))], w - 1)) - rows.append(seg([(RST, " "), (GRID, "─" * len(bars))], w - 1)) - left = "youngest %s" % span(drawn[0][0]) - right = "oldest %s" % span(drawn[-1][0]) - note = ("%d of %d PRs" % (len(drawn), len(ages)) - if len(drawn) < len(ages) else "%d PRs" % len(drawn)) - mid = max(1, len(bars) - len(left) - len(right) - len(note) - 2) - rows.append(seg([(DIM, " " + left), - (DIM, " " * (mid // 2)), (GRID, note), - (DIM, " " * (mid - mid // 2 + 2)), - (DIM, right)], w - 1)) - fattest = max(prs, key=lambda p: (p.get("additions") or 0) - + (p.get("deletions") or 0)) - old_txt, old_col = worst(ages, WARN) - idle_txt, idle_col = worst(idles, WARN) - rows.append(seg([(DIM, " oldest "), (old_col, pad(old_txt, 12)), - (DIM, " untouched longest "), (idle_col, pad(idle_txt, 12)), - (DIM, " biggest "), - (TXT, "#%d +%d/-%d" % (fattest["number"], - fattest["additions"], - fattest["deletions"]))], w - 1)) - return rows - - -def sort_prs(prs, field, newest_first): - key = "updatedAt" if field == "updated" else "createdAt" - return sorted(prs, key=lambda p: p.get(key) or "", reverse=newest_first) - - -def matches(pr, needle): - if not needle: - return True - hay = " ".join([ - str(pr.get("number") or ""), pr.get("title") or "", - (pr.get("author") or {}).get("login") or "", - (pr.get("repository") or {}).get("nameWithOwner") or "", - pr.get("headRefName") or "", pr.get("baseRefName") or "", - ]).lower() - return needle.lower() in hay - - -def main(): - maybe_help(__doc__) - args = sys.argv[1:] - while args and args[0] in ("-n", "--refresh"): - global REFRESH - REFRESH = float(args[1]) - args = args[2:] - store = Store(args) - threading.Thread(target=store.run, daemon=True).start() - setup() - keyboard = Keyboard() - selected, tick, first = 0, 0, 0 - sort_field, newest_first = SORTS[0], True - needle, typing = "", False - show_stats = True - stack_sel = 0 - copied, copied_at = "", 0.0 - source_filter = "all" - - while True: - tick += 1 - (prs, total, detail, stack_rows, loading, err, fetched, - active_query, stages) = store.snapshot() - shown = [p for p in sort_prs(prs, sort_field, newest_first) - if matches(p, needle) - and (source_filter == "all" - or source_filter in (p.get("sources") or []))] - - for key in keyboard.poll(): - if typing: - # while filtering, keys are text - only escape and enter are - # navigation, or the filter could never contain "q" - if key == "esc": - needle, typing = "", False - elif key == "enter": - typing = False - elif key == "backspace": - needle = needle[:-1] - elif len(key) == 1 and key.isprintable(): - needle += key - continue - if key in ("q", "Q"): - raise SystemExit(0) - if key == "/": - typing = True - elif key == "esc": - if detail or loading: - store.close_detail() - stack_sel = 0 - else: - needle = "" - elif key == "enter": - if detail and stack_rows: - # walk the stack from inside the stack: the row under the - # cursor becomes the PR on screen - node = stack_rows[min(stack_sel, - len(stack_rows) - 1)][1] - owner_repo = "/".join( - (detail.get("url") or "").split("/")[3:5]) - if owner_repo and node["number"] != detail["number"]: - store.open_detail({"repository": - {"nameWithOwner": owner_repo}, - "number": node["number"]}) - stack_sel = 0 - elif shown and not detail and not loading: - store.open_detail(shown[min(selected, len(shown) - 1)]) - elif key == "c": - # the URL of whatever is on screen: the open PR in the - # dashboard, the highlighted row in the list - target = detail if detail else ( - shown[min(selected, len(shown) - 1)] if shown else None) - url = (target or {}).get("url") - if url: - copied = url if clipboard(url) else "no clipboard: " + url - copied_at = time.time() - elif key == "r": - store.wake.set() - elif key == "f": - # every PR remembers which sources found it, so narrowing to - # one is instant and costs no request - names = ["all"] + list(_CFG["sources"].keys()) - source_filter = cycle(names, source_filter) - elif key == "s": - sort_field = cycle(SORTS, sort_field) - elif key == "o": - newest_first = not newest_first - elif key == "t": - show_stats = not show_stats - elif key == "up": - if detail: - stack_sel = max(0, stack_sel - 1) - else: - selected = max(0, selected - 1) - elif key == "down": - if detail: - stack_sel += 1 - else: - selected += 1 - - w, h = size() - rows = [title("pr watch", w, PR)] - head = [(DIM, " %d of %d" % (len(shown), total)), - (DIM, " shown" if needle or source_filter != "all" else " open"), - (DIM, " updated %s ago" % ago( - datetime.datetime.fromtimestamp(fetched, - datetime.timezone.utc) - if fetched else None))] - if _RATE["remaining"] is not None: - head.append((DIM, " %d api" % _RATE["remaining"])) - if copied and time.time() - copied_at < 4: - head.append((OK, " copied ")) - head.append((DIM, copied[:max(10, w - 46)])) - rows.append(seg(head, w - 1)) - if err: - rows.append(seg([(BAD, " ! " + err)], w - 1)) - - if detail or loading: - if stack_rows: - stack_sel = max(0, min(stack_sel, len(stack_rows) - 1)) - rows += detail_view(detail, stack_rows, stack_sel, loading, w, h, - tick, stages, store.target, top=len(rows)) - hints = [[(DIM, "[c]opy url")], [(DIM, "[esc] back")], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]] - if stack_rows: - hints = ([[(ACCENT, "↑↓"), (DIM, " stack")], - [(DIM, "[↵] open it")]] + hints) - else: - selected = max(0, min(selected, len(shown) - 1)) if shown else 0 - # the stats cost eight rows; below thirty they would leave the - # list too short to be a list, so they stand down without asking - if show_stats and h >= 30: - # every open PR, not `shown`: the filter is a search of the - # board, not a redefinition of it - rows += stats_view(sort_prs(prs, sort_field, newest_first), w) - rows += list_view(shown, selected, sort_field, newest_first, - needle, typing, w, h, tick, not fetched, - source_filter, top=len(rows)) - hints = [[(ACCENT, "↑↓"), (DIM, " select")], [(DIM, "[↵] open")], - [(DIM, "[/]filter")], - [(DIM, "[s]ort %s" % sort_field)], - [(DIM, "[o]rder %s" % ("newest" if newest_first - else "oldest"))], - [(DIM, "[f]rom %s" % source_filter)], - [(DIM, "[t]stats %s" % ("on" if show_stats else "off"))], - [(DIM, "[c]opy url")], [(DIM, "[r]efresh")], - [(DIM, "[q]uit")]] - if typing: - hints = [[(ACCENT, "/" + needle + "▌")], - [(DIM, "[↵] keep")], [(DIM, "[esc] clear")]] - footer = [" " + line for line in pack_hints(hints, w - 2)] - rows = rows[:h - len(footer)] - while len(rows) < h - len(footer): - rows.append("") - rows.extend(footer) - draw(rows, w, h) - time.sleep(0.3) - - -def list_view(prs, selected, sort_field, newest_first, needle, typing, - w, h, tick, waiting, source_filter="all", top=0): - rows = [""] - arrow = "↓" if newest_first else "↑" - rows.append(seg([(LBL, " ── OPEN PRs ── "), - (DIM, "by %s %s" % (sort_field, arrow)), - (DIM, " from %s" % source_filter - if source_filter != "all" else ""), - (ACCENT, " /%s" % needle if needle else "")], w - 1)) - if not prs: - # "collecting" is only true before the first fetch: an empty filter - # or an empty source is a result, not a wait - if needle: - why = " nothing matches /%s" % needle - elif source_filter != "all": - why = " no open PRs from %s" % source_filter - elif waiting: - why = " collecting…" - else: - why = " no open PRs" - rows.append(seg([(DIM, why)], w - 1)) - return rows - - # Columns are budgeted rather than guessed: the fixed ones are summed and - # the title takes exactly what is left, so nothing runs off the right edge - # or into its neighbour. - wide = w >= 96 - repo_w = 18 if wide else 0 - size_w = 12 if wide else 0 - fixed = 8 + repo_w + 13 + 8 + 6 + size_w - title_w = max(16, w - 1 - fixed) - head = " %-7s" % "PR" - if repo_w: - head += "%-*s" % (repo_w, "REPO") - # the time column follows the sort, so the number you ordered by is the - # number you can see. Labelling both "AGE" had it reporting idle time - # while the stats above reported true age, and the two disagreed. - when_label = "AGE" if sort_field == "created" else "IDLE" - head += "%-*s%13s%8s%6s" % (title_w, "TITLE", "REVIEW", "CHECKS", - when_label) - if size_w: - head += "%*s" % (size_w, "SIZE") - rows.append(DIM + pad(head, w - 1)) - - # `top` is what was drawn above this view. Without it the window is sized - # as though the list began at the top of the screen, so it renders far more - # rows than are visible, the caller truncates the overflow, and the - # selection scrolls off the bottom while `first` is still 0. - room = max(1, h - top - len(rows) - 3) - first = 0 - if len(prs) > room: - first = min(max(0, selected - room // 2), len(prs) - room) - for i, pr in list(enumerate(prs))[first:first + room]: - here = i == selected - tint = bg(38, 56, 76) if here else "" - rlabel, rcol = REVIEW_LABEL.get(pr.get("reviewDecision"), - REVIEW_LABEL[None]) - clabel, ccol = CHECK_LABEL.get(rollup(pr), CHECK_LABEL[None]) - stacked = bool(pr.get("stackEntry")) - line = [(tint + (ACCENT if here else PR), - ("▸" if here else " ") + pad("#%d" % pr["number"], 7))] - if repo_w: - # clip one short of the column so it never touches the title - repo = pr["repository"]["nameWithOwner"].split("/")[-1] - line.append((tint + DIM, pad(repo[:repo_w - 1], repo_w))) - name = ("⣿ " if stacked else "") + (pr.get("title") or "") - if pr.get("isDraft"): - name = "draft · " + name - line += [(tint + TXT, pad(name[:title_w - 1], title_w)), - (tint + rcol, "%13s" % rlabel), - (tint + ccol, "%8s" % clabel), - (tint + DIM, "%6s" % ago(pr.get( - "createdAt" if sort_field == "created" - else "updatedAt")))] - if size_w: - line.append((tint + DIM, "%*s" % (size_w, "+%d/-%d" - % (pr["additions"], - pr["deletions"])))) - if here: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - return rows - - -def detail_view(pr, stack_rows, stack_sel, loading, w, h, tick, stages=(), - target="", top=0): - rows = [""] - if loading or not pr: - # A shimmer says "wait" and nothing else. The open really does run in - # stages, so show them: a spinner on the one in flight, a tick and a - # duration on the ones behind it. Honest, and it reads like a machine - # doing something rather than a placeholder. - rows.append(seg([(LBL, " ── OPENING ── "), (ACCENT, target)], w - 1)) - rows.append("") - spin = SPINNER[tick % len(SPINNER)] - for st in stages: - done = st["state"] == "done" - rows.append(seg([ - (OK if done else ACCENT, " %s " % ("✓" if done else spin)), - (TXT if done else DIM, pad(st["label"], max(20, w - 22))), - (DIM, "%6s" % ("%.1fs" % st["took"] if st["took"] else ""))], - w - 1)) - if not stages: - rows.append(seg([(ACCENT, " %s " % spin), - (DIM, "connecting")], w - 1)) - rows.append("") - # one sweeping line rather than four fat bars of shimmer - rows.append(seg([(RST, " ")] + skeleton(max(10, w - 6), tick * 2), - w - 1)) - return rows - - draft = " · draft" if pr.get("isDraft") else "" - rows.append(seg([(PR, " #%d " % pr["number"]), - (TXT, (pr.get("title") or "")[:max(10, w - 24)]), - (DIM, draft)], w - 1)) - rows.append(seg([(DIM, " "), (DIM, (pr.get("author") or {}).get("login") - or "?"), - (DIM, " "), (ACCENT, pr.get("headRefName") or "?"), - (DIM, " → "), (ACCENT, pr.get("baseRefName") or "?")], - w - 1)) - - rlabel, rcol = REVIEW_LABEL.get(pr.get("reviewDecision"), - REVIEW_LABEL[None]) - mlabel, mcol = MERGE_LABEL.get(pr.get("mergeStateStatus"), - MERGE_LABEL[None]) - threads = (pr.get("reviewThreads") or {}).get("nodes") or [] - unresolved = sum(1 for t in threads if not t.get("isResolved")) - rows.append("") - cells = [("review", rlabel, rcol), - ("merge", mlabel, mcol), - ("unresolved threads", str(unresolved), - BAD if unresolved else OK), - ("size", "+%d/-%d in %d files" % (pr["additions"], pr["deletions"], - pr["changedFiles"]), TXT), - ("commits", str((pr.get("commitCount") or {}).get("totalCount") - or 0), TXT), - ("opened / updated", "%s ago / %s ago" % (ago(pr.get("createdAt")), - ago(pr.get("updatedAt"))), - TXT)] - label_w = max(len(c[0]) for c in cells) - ncols = 2 if (w - 2) // 2 - label_w - 3 >= 18 else 1 - cw = (w - 2) // ncols - val_w = max(6, cw - label_w - 3) - for n in range(0, len(cells), ncols): - line = [(RST, " ")] - for label, value, colour in cells[n:n + ncols]: - line += [(DIM, " " + pad(label, label_w) + " "), - (colour, pad(value, val_w))] - rows.append(seg(line, w - 1)) - - # ── who has looked at it ───────────────────────────────────────────── - # last state per person wins: someone who requested changes and later - # approved has approved, and showing both would misreport the gate - latest = {} - for r in (pr.get("reviews") or {}).get("nodes") or []: - who = (r.get("author") or {}).get("login") - if who: - latest[who] = r.get("state") - pending = [] - for n in (pr.get("reviewRequests") or {}).get("nodes") or []: - who = n.get("requestedReviewer") or {} - name = who.get("login") or who.get("name") - if name and name not in latest: - pending.append(name) - groups = [("approved", OK, [k for k, v in latest.items() if v == "APPROVED"]), - ("changes requested", BAD, - [k for k, v in latest.items() if v == "CHANGES_REQUESTED"]), - ("commented", DIM, - [k for k, v in latest.items() if v == "COMMENTED"]), - ("awaiting", WARN, pending)] - rows.append("") - live = [g for g in groups if g[2]] - rows.append(seg([(LBL, " ── REVIEWERS ── "), - (DIM, "nobody has been asked" if not live - else " · ".join("%d %s" % (len(g[2]), g[0]) - for g in live))], w - 1)) - for label, colour, who in live: - rows.append(seg([(DIM, " " + pad(label, 18)), - (colour, ", ".join(sorted(who))[:max(10, w - 24)])], - w - 1)) - - # ── checks ─────────────────────────────────────────────────────────── - node = ((pr.get("commits") or {}).get("nodes") or [{}]) - roll = ((node[0].get("commit") or {}).get("statusCheckRollup") - if node else None) - rows.append("") - if not roll: - rows.append(seg([(LBL, " ── CHECKS ── "), - (DIM, "none on the last commit")], w - 1)) - else: - state, scol = CHECK_LABEL.get(roll.get("state"), CHECK_LABEL[None]) - ctx = [c for c in (roll.get("contexts") or {}).get("nodes") or [] if c] - rows.append(seg([(LBL, " ── CHECKS ── "), (scol, state), - (DIM, " %d total" % len(ctx))], w - 1)) - bad = [c for c in ctx if (c.get("conclusion") or c.get("state")) - not in ("SUCCESS", "NEUTRAL", "SKIPPED", None)] - # failures first: a green wall of passing checks is not why anyone - # opens this view - for c in (bad + [c for c in ctx if c not in bad])[:8]: - name = c.get("name") or c.get("context") or "?" - verdict = c.get("conclusion") or c.get("state") or c.get("status") - lab, col = CHECK_LABEL.get(verdict, (str(verdict or "—").lower(), - DIM)) - took = "" - a, b = parse(c.get("startedAt")), parse(c.get("completedAt")) - if a and b: - took = "%ds" % int((b - a).total_seconds()) - rows.append(seg([(DIM, " "), (TXT, pad(name, max(12, w - 30))), - (col, "%10s" % lab), (DIM, "%8s" % took)], w - 1)) - - # ── the stack, when there is one ───────────────────────────────────── - if stack_rows: - native = bool(pr.get("stack")) - rows.append("") - # the stack scrolls: eleven-deep stacks exist, and a pane that has - # already spent its height on checks cannot show them all - room = max(3, h - top - len(rows) - 4) - first = 0 - if len(stack_rows) > room: - first = min(max(0, stack_sel - room // 2), len(stack_rows) - room) - rows.append(seg([(LBL, " ── STACK ── "), - (DIM, "%d pull requests · %s" % ( - len(stack_rows), - "from GitHub" if native - else "inferred from branches")), - (ACCENT, " ↑↓ %d-%d of %d" - % (first + 1, min(first + room, len(stack_rows)), - len(stack_rows)) - if len(stack_rows) > room else "")], w - 1)) - rows.append(seg([(DIM, " merge bottom-up: "), - (TXT, "the one nearest the base branch first"), - (DIM, " ▸ cursor · ● on screen")], w - 1)) - base = pr.get("baseRefName") if not native else ( - pr["stack"].get("baseRefName") or "") - rows.append(seg([(DIM, " "), (ACCENT, base or "trunk")], w - 1)) - for idx, (twig, node, is_here, position) in list( - enumerate(stack_rows))[first:first + room]: - lab, col = REVIEW_LABEL.get(node.get("reviewDecision"), - REVIEW_LABEL[None]) - merge = node.get("mergeable") - mlab, mcol = (("CONFLICT", BAD) if merge == "CONFLICTING" - else ("ok", OK) if merge == "MERGEABLE" - else ("…", DIM)) - on_cursor = idx == stack_sel - tint = bg(38, 56, 76) if on_cursor else "" - name = (node.get("title") or "") - if position: - name = "%d. %s" % (position, name) - # two gutter marks, because they answer different questions: - # ▸ is where the cursor is, ● is the PR actually on screen. One - # symbol plus a colour could not say both. - gutter = ("▸" if on_cursor else " ") + ("●" if is_here else " ") - rows.append(seg([ - (tint + (ACCENT if on_cursor else DIM), gutter), - (tint + DIM, twig), - (tint + (ACCENT if on_cursor else PR), - "#%-5d " % node["number"]), - (tint + (TXT if (is_here or on_cursor) else DIM), - pad(name[:max(10, w - 34 - len(twig))], - max(10, w - 34 - len(twig)))), - (tint + col, "%13s" % lab), - (tint + mcol, "%10s" % mlab), - ] + ([(tint, " " * w)] if on_cursor else []), w - 1)) - return rows - - -main() diff --git a/start.py b/start.py deleted file mode 100755 index 929751a..0000000 --- a/start.py +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Every widget here, what it does, and whether it will work on this machine. - -Thirteen scripts in a directory is a list you have to already know. This is -the front door: pick one and it runs, quit it and you are back here. - - python3 start.py [WIDGET] [ARGS...] - -Nothing is described twice. The name and the summary are each widget's own -first docstring line, and the note underneath is the paragraph that follows -it - both already maintained, and already checked by check.py, so a widget -cannot appear here saying something its own file does not. - -Nothing is said here about whether a widget will work. A widget that cannot -run says so itself, on its own screen, in its own words - which is where -somebody who has just tried to start it is already looking, and is the only -place that knows what it actually needs. - -Keys: up/down select, enter launches, r rechecks, q quits. -""" -import ast -import glob -import os -import subprocess -import sys -import termios -import time -import tty - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (CLEAR, HIDE, HOME, RST, SHOW, Keyboard, bg, draw, flush, - maybe_help, out, pack_hints, pad, rgb, seg, setup, size, - title) - -HERE = os.path.dirname(os.path.abspath(__file__)) -# Not widgets: the shared library, the checker, and this. -NOT_A_WIDGET = ("common.py", "check.py", "start.py", - "__main__.py") - -DIM = rgb(127, 147, 172) -GRID = rgb(60, 78, 98) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) - - - -def wrap(text, width): - """Break a paragraph at spaces, for the note under the list.""" - lines, rest = [], (text or "").strip() - while rest and len(lines) < 3: - if len(rest) <= width: - lines.append(rest) - break - cut = rest.rfind(" ", 0, width + 1) - cut = cut if cut > width // 2 else width - lines.append(rest[:cut]) - rest = rest[cut:].lstrip() - return lines - - -def widgets(): - """Every widget beside this script, with its own description of itself.""" - found = [] - for path in sorted(glob.glob(os.path.join(HERE, "*.py"))): - name = os.path.basename(path) - if name in NOT_A_WIDGET: - continue - try: - doc = ast.get_docstring(ast.parse(open(path).read())) or "" - except (OSError, SyntaxError): - doc = "" - lines = doc.splitlines() - # The first line is the row; the first paragraph under it is the - # aside, which is where each widget explains why it exists. Only - # that paragraph: what follows is the usage synopsis and the key - # list, which are for somebody reading --help, not somebody - # deciding whether this is the thing they want. - para = [] - for line in lines[2:]: - if line.startswith(" "): # an indented usage block - break - if not line.strip(): - if para: - break - continue - para.append(line.strip()) - about = " ".join(para) - found.append({"file": name, "stem": name[:-3], - "summary": lines[0] if lines else "", - "about": about[:400], "sample": sample(name[:-3])}) - return found - - -def sample(stem): - """The picture from this widget's doc page, if it has one. - - Every doc opens with a rendering of the widget it describes, kept by - whoever wrote it and read by whoever is deciding whether to run the - thing. Using that means no second copy of anything - and, more to the - point, no widget has to be started to be looked at. - """ - path = os.path.join(HERE, "docs", "%s.md" % stem) - try: - text = open(path).read() - except OSError: - return [] - block, inside = [], False - for line in text.splitlines(): - if line.startswith("```"): - if inside: - break - inside = True - continue - if inside: - block.append(line) - # Only a block that is actually a picture of the widget: the docs also - # hold shell snippets and JSON, and a config listing is not a preview. - return block if block and block[0].startswith("╺━") else [] - - -def rows_for(items, w, selected): - name_w = max(12, min(18, w - 58)) - # The column exists only if something is in it. With nothing to do on - # this machine - the common case - it takes no width at all and the - # descriptions get it instead. - # Every column keeps a space of its own, so a description that fills its - # width stops short of whatever is beside it rather than running into it. - text_w = max(8, (w - 1) - name_w - 6) - out_rows = [] - for i, item in enumerate(items): - here = i == selected - tint = bg(28, 44, 62) if here else "" - line = [(tint + (ACCENT if here else DIM), " ▸ " if here else " "), - (tint + (TXT if here else LBL), - pad(item["stem"][:name_w - 1], name_w)), - (tint + DIM, pad(item["summary"][:text_w - 1], text_w))] - if here: - line.append((tint, " " * w)) - out_rows.append(seg(line, w - 1)) - return out_rows - - -def run_widget(keyboard, item, extra=()): - """Hand the terminal over, and take it back when the widget exits.""" - keyboard.restore() - out(SHOW + RST + CLEAR + HOME) - flush() - try: - subprocess.call([sys.executable, - os.path.join(HERE, item["file"])] + list(extra)) - except OSError as exc: - out("%s\r\n" % exc) - flush() - # The widget left the terminal however it left it, so take it back - # rather than assuming: raw mode again, cursor away again, screen clear. - if keyboard.fd is not None: - try: - tty.setcbreak(keyboard.fd) - except (termios.error, ValueError): - pass - out(HIDE + CLEAR + HOME) - flush() - - -def collect(): - return widgets() - - -def main(): - args = sys.argv[1:] - items = collect() - # A widget name is resolved before --help is looked at, so that - # `start.py netwatch --help` is netwatch's help, not this one's. Every - # argument after the name belongs to the widget, including that one. - if args and not args[0].startswith("-"): - # A name, so run it straight away and pass the rest through: the menu - # is for browsing, not something to sit between you and a widget you - # already know the name of. - wanted = args[0][:-3] if args[0].endswith(".py") else args[0] - match = next((i for i in items if i["stem"] == wanted), None) - if match is None: - sys.stderr.write("no widget called %r - try: %s\n" - % (args[0], ", ".join(i["stem"] for i in items))) - raise SystemExit(2) - os.execv(sys.executable, [sys.executable, - os.path.join(HERE, match["file"])] + args[1:]) - - maybe_help(__doc__) - setup() - keyboard = Keyboard() - selected = 0 - while True: - for key in keyboard.poll(): - if key in ("q", "Q"): - raise SystemExit(0) - if key in ("up", "k", "K"): - selected -= 1 - elif key in ("down", "j", "J"): - selected += 1 - elif key in ("r", "R"): - items = collect() - elif key in ("enter", "right", "i") and items: - run_widget(keyboard, items[min(selected, len(items) - 1)]) - items = collect() - - w, h = size() - selected = max(0, min(selected, len(items) - 1)) if items else 0 - - body = [title("terminal toys", w, ACCENT)] - body.append(seg([(DIM, " %d widgets ↵ starts one, q leaves" - % len(items))], w - 1)) - body.append("") - if not items: - body.append(seg([(DIM, " No widgets beside this script.")], - w - 1)) - else: - body.extend(rows_for(items, w, selected)) - body.append("") - # What the highlighted one is for, in its own words - the rest of - # its opening paragraph, which the row has no room for. Not the - # command to run it: that is this screen's job, not the reader's. - pick = items[selected] if items else None - if pick and h - len(body) >= 3: - body.append(seg([(LBL, " ── %s ── " % pick["stem"].upper())], - w - 1)) - tall = h - len(body) >= 12 - for line in wrap(pick["about"], w - 4)[:1 if tall else 3]: - body.append(seg([(DIM, " " + line)], w - 1)) - - # And what it looks like. A picture from the docs rather than the - # widget itself: starting one to look at it would ping hosts, spend - # API quota and read the whole agent transcript tree, and browsing a - # menu should cost nothing at all. - # Measured against the footer that will actually be drawn, rather - # than a guess at its height. - hints = [[(ACCENT, "↑↓"), (DIM, " select")], - [(ACCENT, "↵"), (DIM, " launch")], - [(DIM, "[r]echeck")], [(DIM, "[q]uit")]] - foot = [" " + line for line in pack_hints(hints, w - 2)] - room = h - len(body) - len(foot) - shown = pick["sample"] if pick else [] - if shown and room >= 6 and w >= 44: - rule = "─" * max(1, w - 15) - body.append(seg([(GRID, " ┌── "), (DIM, "example"), - (GRID, " " + rule + "┐")], w - 1)) - for line in shown[:room - 1]: - body.append(seg([(GRID, " │"), (DIM, line[:w - 4])], w - 1)) - - while len(body) < h - len(foot): - body.append("") - body.extend(foot) - draw(body[:h], w, h) - time.sleep(0.15) - - -main() diff --git a/tailnet.py b/tailnet.py deleted file mode 100755 index 8bdefdf..0000000 --- a/tailnet.py +++ /dev/null @@ -1,893 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Tailscale network: who is online, and how you are reaching them. - -RX and TX are traffic between *this* host and that peer, counted by the local -WireGuard engine — not the peer's own totals. They reset when tailscaled -restarts, so they cover that window rather than all time. - -The column that matters is PATH. A peer is either DIRECT, meaning NAT traversal -succeeded and traffic goes peer-to-peer, or it is relayed through a named DERP -region, meaning every packet round-trips through Tailscale's infrastructure. -Relayed peers can be dramatically slower and the difference is invisible in -`tailscale status` output unless you look for it. - -Peers advertising subnet routes are flagged, since those only reach you if this -node runs with --accept-routes. - -The info view names each peer's home DERP region — the Tailscale POP nearest to -it — as a location hint. That comes from the local DERP map, so no address is -ever sent to a geolocation service. - - python3 tailnet.py [-n SECONDS] - -A live throughput section graphs peers currently moving data (toggle with g), -and the info view carries the same graph for the selected machine plus ICMP -latency over the tunnel — current, average, median, min, max, jitter, loss and -a sparkline — measured the same way the latency monitor does. Only the selected -peer is probed, so this costs one ping process regardless of tailnet size. - -n cycles the poll interval while running (1/2/5/10/30s), the same way the -latency monitor's i key does; the graph resolution follows it. -n sets the -starting value, and `tailnet.refresh` in config.json sets the default. - -Keys: up/down select a peer, Enter or i opens a full machine info view (every address, -routes, tags, owner, handshake times), c or Enter opens a copy sheet offering its -Tailscale IP, MagicDNS name, public IP and LAN IP, r refreshes now, o hides -offline peers, q quits. Copying uses OSC 52, so it reaches the clipboard of -the machine you are typing at even over SSH. - -Needs the `tailscale` CLI. Peer LAN addresses come from `tailscale debug -netmap`, which needs root; it is attempted with `sudo -n` and simply omitted -when that would prompt, so nothing here requires privilege. -""" -import collections -import json -import os -import re -import subprocess -import sys -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (RST, Keyboard, bg, clipboard, cycle, draw, load_config, - maybe_help, pack_hints, pad, rgb, seg, setup, size, title) - -_CFG = load_config("tailnet", {"refresh": 2.0, "history": 180}) -REFRESH = float(_CFG["refresh"]) -HISTORY = int(_CFG["history"]) # rate samples kept per peer -REFRESH_CHOICES = (1.0, 2.0, 5.0, 10.0, 30.0) # cycled at runtime with n -SPARK = "▁▂▃▄▅▆▇█" - -ONLINE = rgb(90, 240, 160) -OFFLINE = rgb(120, 130, 150) -DIRECT = rgb(90, 240, 160) -RELAY = rgb(255, 190, 90) -DIM = rgb(127, 147, 172) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(120, 200, 255) -ROUTE = rgb(200, 160, 255) -EXIT = rgb(255, 140, 200) - - -def daemon_uptime(): - """Seconds since tailscaled started. - - Its byte counters live in memory and reset with it, so RX/TX cover this - window rather than all time — a peer reading 0B may just predate a restart. - """ - try: - for pid in os.listdir("/proc"): - if not pid.isdigit(): - continue - try: - with open("/proc/%s/comm" % pid) as f: - if f.read().strip() != "tailscaled": - continue - with open("/proc/%s/stat" % pid) as f: - started = int(f.read().rpartition(")")[2].split()[19]) - with open("/proc/uptime") as f: - up = float(f.read().split()[0]) - except (OSError, IndexError, ValueError): - continue - return up - started / float(os.sysconf("SC_CLK_TCK")) - except OSError: - pass - return None - - -def peer_name(peer): - """Tailnet-unique display name. - - HostName is whatever the device calls itself and is frequently useless: - iPads, Chromecasts and Pixels all report "localhost", and two Apple TVs - report the same "apple-tv". The first label of the MagicDNS name is unique - across the tailnet and matches what the admin console shows. - """ - dns = (peer.get("DNSName") or "").rstrip(".") - if dns: - return dns.split(".")[0] - return peer.get("HostName") or "?" - - -def classify(ip): - """public | private | tailscale | other, from the address alone.""" - ip = ip.split("%")[0] - if ":" in ip: - return "tailscale" if ip.lower().startswith("fd7a:") else "other" - try: - a, b = (int(x) for x in ip.split(".")[:2]) - except ValueError: - return "other" - if a == 100 and 64 <= b <= 127: - return "tailscale" # CGNAT range Tailscale itself uses - if a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168): - return "private" - if a == 169 and b == 254: - return "other" # link-local - if a == 127: - return "other" - return "public" - - -def in_network(ip, cidr): - """Is an IPv4 address inside a CIDR block?""" - net, _, bits = cidr.partition("/") - if ":" in net or not bits: - return False - try: - bits = int(bits) - to_int = lambda a: sum(int(o) << (24 - 8 * i) - for i, o in enumerate(a.split("."))) - mask = (0xffffffff << (32 - bits)) & 0xffffffff - return (to_int(ip) & mask) == (to_int(net) & mask) - except (ValueError, IndexError): - return False - - -def lan_rank(ip, routes): - """Lower is better. Prefers a real LAN address over a virtual bridge. - - A peer often exposes several private endpoints, and docker0 (172.17.0.1) - or a k8s bridge is not the address anyone wants to copy. An address inside - a subnet the peer advertises is almost certainly its real LAN address. - """ - if any(in_network(ip, r) for r in routes): - return 0 - first, second = (int(x) for x in ip.split(".")[:2]) - if first == 192 and second == 168: - return 1 - if first == 10: - return 2 - return 3 # 172.16-31: usually docker/virtual - - -def endpoints_by_peer(): - """Peer LAN/public endpoints from the netmap, which needs root. - - Optional enrichment: `tailscale status` does not carry peer endpoints, so - without this the panel simply offers fewer addresses to copy. Uses sudo -n - so it fails instantly rather than prompting when sudo needs a password. - """ - try: - out = subprocess.run(["sudo", "-n", "tailscale", "debug", "netmap"], - capture_output=True, text=True, timeout=25) - data = json.loads(out.stdout) - except Exception: - return {} - found = {} - for peer in (data.get("Peers") or []): - name = (peer.get("Name") or "").rstrip(".") - if name: - found[name] = [e.split(":")[0] for e in (peer.get("Endpoints") or [])] - return found - - -_DERP = {} - - -def derp_regions(): - """DERP region code -> city, from the local map. - - A peer's home region is the Tailscale POP nearest to it, so this gives a - location hint without sending anyone's IP to a geolocation service. Cached: - the map changes rarely and is the same for every peer. - """ - if _DERP: - return _DERP - try: - out = subprocess.run(["tailscale", "debug", "derp-map"], - capture_output=True, text=True, timeout=20) - data = json.loads(out.stdout) - except Exception: - return _DERP - for region in (data.get("Regions") or {}).values(): - code = region.get("RegionCode") - if code: - _DERP[code] = region.get("RegionName") or code - return _DERP - - -def ts_status(): - try: - out = subprocess.run(["tailscale", "status", "--json"], - capture_output=True, text=True, timeout=15) - return json.loads(out.stdout) - except Exception: - return None - - -def _sample_rates(store, data): - """Turn cumulative byte counters into per-second rates.""" - now = time.time() - for peer in (data.get("Peer") or {}).values(): - key = peer_name(peer) - rx, tx = peer.get("RxBytes", 0), peer.get("TxBytes", 0) - prev = store._counters.get(key) - store._counters[key] = (rx, tx, now) - if not prev: - continue - dt = now - prev[2] - if dt <= 0: - continue - # a tailscaled restart zeroes the counters; report no traffic rather - # than a large negative spike - drx = max(0, rx - prev[0]) / dt - dtx = max(0, tx - prev[1]) / dt - hist = store.rates.setdefault(key, collections.deque(maxlen=HISTORY)) - hist.append((drx, dtx)) - - -def spark(values, n, peak=None): - """Sparkline of the last n values, scaled to `peak` or their own max.""" - vals = list(values)[-n:] - if not vals: - return "" - hi = peak if peak else max(vals) - if not hi: - return "·" * len(vals) - return "".join(SPARK[min(7, int(v / hi * 7.99))] for v in vals) - - -def ago(s): - s = int(max(0, s)) - if s < 3600: - return "%dm ago" % (s / 60) - if s < 172800: - return "%dh ago" % (s / 3600) - return "%dd ago" % (s / 86400) - - -def human(n): - """Byte count in exactly five cells, so columns stay aligned.""" - n = float(n or 0) - for u in ("B", "K", "M", "G", "T"): - if n < 1024: - body = "%.0f%s" % (n, u) if (n >= 10 or u == "B") else "%.1f%s" % (n, u) - return "%5s" % body - n /= 1024.0 - return "%5s" % ("%.0fP" % n) - - -def seen(iso): - """Age of an ISO-8601 timestamp, coarsely.""" - if not iso: - return " -" - try: - t = time.mktime(time.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S")) - except ValueError: - return " -" - s = max(0, time.time() - t - time.timezone) - if s < 90: - return "now" - if s < 5400: - return "%dm" % (s / 60) - if s < 172800: - return "%dh" % (s / 3600) - return "%dd" % (s / 86400) - - -TIME_RE = re.compile(r"time[=<]([\d.]+)\s*ms") - - -class Prober(object): - """Pings whichever peer is selected, keeping per-peer history. - - Probing every peer continuously would mean two dozen ping processes for - data nobody is looking at, so exactly one runs at a time and follows the - selection. History is kept per peer, so returning to one still shows its - earlier samples. - """ - - def __init__(self): - self.lock = threading.Lock() - self.samples = {} # machine -> deque of rtt|None - self.want = None # (machine, ip) - self.proc = None - - def watch(self, machine, ip): - with self.lock: - if self.want and self.want[0] == machine: - return - self.want = (machine, ip) if ip else None - if self.proc and self.proc.poll() is None: - try: - self.proc.terminate() - except OSError: - pass - - def history(self, machine): - with self.lock: - return list(self.samples.get(machine) or []) - - def run(self): - # A daemon thread that raises just stops, and a dead poller looks - # exactly like a source with no data - which is how deployments.py - # showed "0 deploys" for a day after an import went missing. - try: - self.poll() - except Exception as e: - with self.lock: - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:70]) - - def poll(self): - while True: - with self.lock: - target = self.want - if not target: - time.sleep(0.4) - continue - machine, ip = target - try: - self.proc = subprocess.Popen( - ["ping", "-n", "-O", "-i", "1", ip], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, bufsize=1) - except OSError: - time.sleep(2) - continue - for line in self.proc.stdout: - with self.lock: - if not self.want or self.want[0] != machine: - break - hist = self.samples.setdefault( - machine, collections.deque(maxlen=120)) - m = TIME_RE.search(line) - if m: - hist.append(float(m.group(1))) - elif "no answer yet" in line or "Unreachable" in line: - hist.append(None) - try: - self.proc.terminate() - except OSError: - pass - - -class Store(object): - def __init__(self): - self.lock = threading.Lock() - self.data = None - self.endpoints = {} - self.error = None - self.wake = threading.Event() - self._endpoints_at = 0 - self.rates = {} # machine -> deque of (rx_per_s, tx_per_s) - self._counters = {} # machine -> (rx, tx, when) - - def snapshot(self): - with self.lock: - return (self.data, dict(self.endpoints), self.error, - {k: list(v) for k, v in self.rates.items()}) - - def _sample(self, data): - _sample_rates(self, data) - - def run(self): - while True: - d = ts_status() - eps = None - if time.time() - self._endpoints_at > 60: - eps = endpoints_by_peer() - self._endpoints_at = time.time() - with self.lock: - if d is None: - self.error = "tailscale CLI unavailable or not logged in" - else: - self.data, self.error = d, None - if eps: - self.endpoints = eps - if d: - self._sample(d) - self.wake.wait(REFRESH) - self.wake.clear() - - -def addresses(peer, eps): - """The addresses worth copying for a peer, as (label, value) pairs.""" - out = [] - ips = peer.get("TailscaleIPs") or [] - v4 = [i for i in ips if ":" not in i] - v6 = [i for i in ips if ":" in i] - if v4: - out.append(("Tailscale IP", v4[0])) - dns = (peer.get("DNSName") or "").rstrip(".") - if dns: - out.append(("MagicDNS name", dns)) - - seen_pub, seen_priv = [], [] - cur = (peer.get("CurAddr") or "").rsplit(":", 1)[0] - if cur and classify(cur) == "public": - seen_pub.append(cur) - for ip in eps.get(dns, []): - kind = classify(ip) - if kind == "public" and ip not in seen_pub: - seen_pub.append(ip) - elif kind == "private" and ip not in seen_priv: - seen_priv.append(ip) - # PrimaryRoutes only: AllowedIPs also carries 0.0.0.0/0 for exit nodes, - # which would match every address and defeat the ranking entirely. - routes = [r for r in (peer.get("PrimaryRoutes") or []) - if r not in ("0.0.0.0/0", "::/0")] - seen_priv.sort(key=lambda i: lan_rank(i, routes)) - if seen_pub: - out.append(("Public IP", seen_pub[0])) - if seen_priv: - out.append(("Private IP (LAN)", seen_priv[0])) - if len(seen_priv) > 1: - out.append(("Other private IP", seen_priv[1])) - if v6: - out.append(("Tailscale IPv6", v6[0])) - return out - - -def stamp(iso): - """ISO-8601 to a readable local time, or None when unset.""" - if not iso or iso.startswith("0001-01-01"): - return None - try: - t = time.mktime(time.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S")) - time.timezone - except ValueError: - return None - return time.strftime("%Y-%m-%d %H:%M", time.localtime(t)) - - -def rate(v): - """Per-second byte rate in six cells.""" - return "%s/s" % human(v).strip().rjust(5) - - -def activity_rows(rates, w, limit): - """Live throughput for peers that are actually moving data.""" - active = [] - for name, hist in rates.items(): - if not hist: - continue - recent = hist[-30:] - if max((r + t for r, t in recent), default=0) < 64: # ignore keepalives - continue - active.append((max(r + t for r, t in recent), name, hist)) - active.sort(reverse=True) - if not active: - return [DIM + " no peer traffic in the last few minutes"] - - sw = max(8, min(28, w - 42)) - peak = max(a[0] for a in active) - out = [] - for _, name, hist in active[:limit]: - rx = [r for r, _ in hist] - tx = [t for _, t in hist] - out.append(seg([(TXT, " " + pad(name[:18], 19)), - (ONLINE, "↓" + spark(rx, sw, peak)), - (RELAY, " ↑" + spark(tx, sw, peak)), - (DIM, " " + rate(rx[-1]) + " " + rate(tx[-1]))], w - 1)) - return out - - -def info_overlay(peer, eps, users, w, h, rates=None, latency=None): - """Everything known about one machine, including every address.""" - rows = [title("machine info", w, ACCENT)] - dns = (peer.get("DNSName") or "").rstrip(".") - ips = peer.get("TailscaleIPs") or [] - owner = (users.get(str(peer.get("UserID"))) or {}).get("LoginName", "") - - def field(label, value, color=TXT): - if value in (None, "", []): - return - rows.append(seg([(DIM, " %-13s" % label), (color, str(value))], w - 1)) - - field("machine", peer_name(peer), ACCENT) - field("dns name", dns) - if (peer.get("HostName") or "") != peer_name(peer): - field("hostname", peer.get("HostName") + " (self-reported)", DIM) - field("os", peer.get("OS")) - region = peer.get("Relay") or "" - if region: - city = derp_regions().get(region) - field("region", "%s — %s" % (region, city) if city else region, ROUTE) - field("owner", owner) - field("tags", ", ".join(peer.get("Tags") or []), ROUTE) - rows.append("") - - up = bool(peer.get("Online")) - field("status", "online" if up else "offline", ONLINE if up else OFFLINE) - if peer.get("CurAddr"): - field("path", "DIRECT via " + peer["CurAddr"], DIRECT) - else: - relay = peer.get("Relay") or "?" - city = derp_regions().get(relay, "") - field("path", "relayed through DERP %s%s" % (relay, - " (%s)" % city if city else ""), - RELAY) - field("rx / tx", "%s / %s" % (human(peer.get("RxBytes")).strip(), - human(peer.get("TxBytes")).strip())) - field("handshake", stamp(peer.get("LastHandshake"))) - field("last seen", stamp(peer.get("LastSeen")) or ("connected" if up else "-")) - field("added", stamp(peer.get("Created"))) - rows.append("") - - rows.append(LBL + " addresses") - for ip in ips: - field(" tailscale", ip, ACCENT) - pub, priv, other = [], [], [] - cur = (peer.get("CurAddr") or "").rsplit(":", 1)[0] - if cur: - pub.append(cur) - for ip in eps.get(dns, []): - kind = classify(ip) - bucket = {"public": pub, "private": priv}.get(kind, other) - if ip not in bucket: - bucket.append(ip) - routes = [r for r in (peer.get("PrimaryRoutes") or []) - if r not in ("0.0.0.0/0", "::/0")] - priv.sort(key=lambda i: lan_rank(i, routes)) - for ip in pub: - field(" public", ip) - for ip in priv: - tag = " (in advertised subnet)" if any(in_network(ip, r) for r in routes) else "" - field(" private", ip + tag) - for ip in other: - field(" other", ip, DIM) - if not eps: - rows.append(DIM + " endpoints need sudo; only the current path is shown") - - if routes: - rows.append("") - rows.append(LBL + " advertises") - for r in routes[:6]: - rows.append(seg([(ROUTE, " " + r)], w - 1)) - if len(routes) > 6: - rows.append(DIM + " +%d more" % (len(routes) - 6)) - pings = [x for x in (latency or []) if x is not None] - if latency: - rows.append("") - loss = 100.0 * (len(latency) - len(pings)) / len(latency) - if pings: - ordered = sorted(pings) - jit = (sum(abs(pings[i] - pings[i - 1]) for i in range(1, len(pings))) - / (len(pings) - 1)) if len(pings) > 1 else 0.0 - rows.append(LBL + " latency " + DIM + "(icmp over tailscale, %d samples)" - % len(latency)) - rows.append(seg([(DIM, " now "), (TXT, "%7.2fms" % pings[-1]), - (DIM, " avg "), (TXT, "%7.2fms" % (sum(pings) / len(pings))), - (DIM, " med "), (TXT, "%7.2fms" % ordered[len(ordered) // 2])], - w - 1)) - rows.append(seg([(DIM, " min "), (TXT, "%7.2fms" % ordered[0]), - (DIM, " max "), (TXT, "%7.2fms" % ordered[-1]), - (DIM, " jit "), (TXT, "%7.2fms" % jit)], w - 1)) - rows.append(seg([(DIM, " loss"), (ONLINE if loss == 0 else RELAY, - "%7.1f%%" % loss)], w - 1)) - lo, hi = ordered[0], ordered[-1] - span = (hi - lo) or 1.0 - sw = max(10, w - 8) - marks = [] - for v in latency[-sw:]: - if v is None: - marks.append((RELAY, "×")) - else: - marks.append((ONLINE, SPARK[min(7, int((v - lo) / span * 7.99))])) - rows.append(" " + "".join(c + ch for c, ch in marks)) - else: - rows.append(LBL + " latency " + RELAY + "no replies") - elif peer.get("Online"): - rows.append("") - rows.append(LBL + " latency " + DIM + "probing…") - - hist = (rates or {}).get(peer_name(peer)) or [] - if hist: - rows.append("") - rows.append(LBL + " throughput " + DIM + "(last %d samples)" % len(hist[-60:])) - rx = [r for r, _ in hist] - tx = [t for _, t in hist] - peak = max(max(rx), max(tx), 1) - sw = max(10, w - 22) - rows.append(seg([(DIM, " down "), (ONLINE, spark(rx, sw, peak)), - (DIM, " " + rate(rx[-1]))], w - 1)) - rows.append(seg([(DIM, " up "), (RELAY, spark(tx, sw, peak)), - (DIM, " " + rate(tx[-1]))], w - 1)) - rows.append(seg([(DIM, " peak %s" % rate(peak))], w - 1)) - - if peer.get("ExitNodeOption"): - rows.append("") - rows.append(seg([(EXIT, " offers itself as an exit node")], w - 1)) - - while len(rows) < h - 1: - rows.append("") - rows.append(seg([(DIM, " [c]opy addresses · esc, ↵ or i to close")], w - 1)) - return rows - - -def wrap(text, width): - return [text[i:i + width] for i in range(0, len(text), width)] or [""] - - -def copy_overlay(peer, eps, w, h, note): - rows = [title("copy address", w, ROUTE)] - rows.append("") - rows.append(seg([(TXT, " " + peer_name(peer)), - (DIM, " " + (peer.get("OS") or "")), - (DIRECT if peer.get("CurAddr") else RELAY, - " " + ("DIRECT" if peer.get("CurAddr") - else "relay " + str(peer.get("Relay") or "?")))], w - 1)) - rows.append("") - pairs = addresses(peer, eps) - for i, (label, value) in enumerate(pairs, 1): - rows.append(seg([(ONLINE, " [%d] " % i), (TXT, label)], w - 1)) - for line in wrap(value, max(10, w - 6)): - rows.append(ACCENT + " " + line) - rows.append("") - if not pairs: - rows.append(DIM + " (no addresses available for this peer)") - if not eps: - rows.append(DIM + " LAN addresses need `sudo tailscale debug netmap`;") - rows.append(DIM + " passwordless sudo is unavailable, so they are omitted.") - while len(rows) < h - 2: - rows.append("") - rows.append(seg([(DIM, " press 1-%d to copy · esc or c to close" % max(1, len(pairs)))], - w - 1)) - rows.append(seg([(ONLINE, " " + note) if note else (DIM, "")], w - 1)) - return rows - - -def main(): - maybe_help(__doc__) - global REFRESH - args = sys.argv[1:] - if args and args[0] in ("-n", "--refresh"): - REFRESH = max(1.0, float(args[1])) - - setup() - keyboard = Keyboard() - store = Store() - th = threading.Thread(target=store.run) - th.daemon = True - th.start() - prober = Prober() - pt = threading.Thread(target=prober.run) - pt.daemon = True - pt.start() - - hide_offline = False - show_graph = True - selected = 0 - scroll = 0 - view = None # None | "copy" | "info" - note = "" - note_until = 0 - listed = [] - visible = 1 - while True: - for key in keyboard.poll(): - if view: - if key == "esc" or key in ("q", "Q"): - view = None - elif key in ("i", "enter"): - view = "info" if view != "info" else None - elif key == "c": - view = "copy" if view != "copy" else None - elif view == "copy" and key.isdigit() and listed: - pairs = addresses(listed[min(selected, len(listed) - 1)], eps_now) - idx = int(key) - 1 - if 0 <= idx < len(pairs): - label, value = pairs[idx] - note = ("✓ copied %s" % label.lower()) if clipboard(value) \ - else "! no clipboard; select the text with the mouse" - note_until = time.time() + 3 - continue - if key in ("q", "Q"): - keyboard.restore() - raise SystemExit(0) - if key == "r": - store.wake.set() - elif key == "o": - hide_offline = not hide_offline - selected = 0 - elif key == "g": - show_graph = not show_graph - elif key == "n": - REFRESH = cycle(REFRESH_CHOICES, REFRESH) - store.wake.set() # apply the new interval immediately - elif key == "up": - selected = max(0, selected - 1) - elif key == "down": - selected += 1 - elif key == "pgup": - selected = max(0, selected - visible) - elif key == "pgdn": - selected += visible - elif key == "home": - selected = 0 - elif key == "end": - selected = max(0, len(listed) - 1) - elif key == "c": - if listed: - view = "copy" - note = "" - elif key in ("i", "enter"): - if listed: - view = "info" - - w, h = size() - data, eps_now, err, rates = store.snapshot() - if note and time.time() > note_until: - note = "" - rows = [title("tailnet", w, ACCENT)] - - if not data: - rows.append(seg([(RELAY, " " + (err or "connecting…"))], w - 1)) - draw(rows, w, h) - time.sleep(0.4) - continue - - me = data.get("Self") or {} - peers = list((data.get("Peer") or {}).values()) - online = [p for p in peers if p.get("Online")] - direct = [p for p in online if p.get("CurAddr")] - relayed = [p for p in online if not p.get("CurAddr")] - routers = [p for p in peers if p.get("PrimaryRoutes")] - exits = [p for p in peers if p.get("ExitNode")] - - rows.append(seg([(TXT, " " + (me.get("DNSName") or "").rstrip(".").split(".")[0]), - (DIM, " " + (me.get("TailscaleIPs") or ["?"])[0]), - (DIM, " " + (data.get("MagicDNSSuffix") or ""))], w - 1)) - rows.append(seg([(ONLINE, " %d online" % len(online)), - (DIM, " / %d peers" % len(peers)), - (DIRECT, " %d direct" % len(direct)), - (RELAY, " %d relayed" % len(relayed)), - (DIM, " every %gs" % REFRESH)], w - 1)) - line = [(ROUTE, " %d advertising routes" % len(routers))] - line.append((EXIT, " exit node: " + (peer_name(exits[0]) if exits - else "none"))) - rows.append(seg(line, w - 1)) - rows.append("") - - if view and listed: - chosen = listed[min(selected, len(listed) - 1)] - if view == "info": - users = {str(k): v for k, v in (data.get("User") or {}).items()} - draw(info_overlay(chosen, eps_now, users, w, h, rates, - prober.history(peer_name(chosen))), w, h) - else: - draw(copy_overlay(chosen, eps_now, w, h, note), w, h) - time.sleep(0.1) - continue - - if show_graph: - rows.append(LBL + " ── LIVE THROUGHPUT ── " + DIM + "peers moving data") - rows.extend(activity_rows(rates, w, 4)) - rows.append("") - - wide = w >= 62 - # machine names are long; spend spare width on them rather than padding - namew = max(16, min(32, w - 45)) if wide else max(12, w - 22) - head = " %s %-8s %-7s" % (pad("MACHINE", namew + 1), "OS", "PATH") - if wide: - head += " %5s %5s %5s" % ("RX", "TX", "SEEN") - rows.append(LBL + pad(head, w - 1)) - if wide: - span = daemon_uptime() - rows.append(seg([(DIM, " rx/tx = this host ↔ peer, since tailscaled " - "started" + ((" " + ago(span)) if span else ""))], - w - 1)) - - def order(p): - return (not p.get("Online"), not p.get("CurAddr"), - -(p.get("RxBytes", 0) + p.get("TxBytes", 0))) - - if listed: - sel_peer = listed[min(selected, len(listed) - 1)] - ip4 = [i for i in (sel_peer.get("TailscaleIPs") or []) if ":" not in i] - # Nothing is learned by pinging ourselves, and the round trip - # would read as a suspiciously good link. - prober.watch(peer_name(sel_peer), - ip4[0] if (ip4 and sel_peer.get("Online") - and not sel_peer.get("_self")) else None) - - listed = [p for p in sorted(peers, key=order) - if not (hide_offline and not p.get("Online"))] - # This machine belongs in the list of machines - it was only ever in - # the header - but never in the counts above it: "3 direct, 2 - # relayed" describes connections out of here, and there is no - # connection from here to here. It pins to the top rather than - # sorting by traffic, because where you are is not a ranking. - if me: - listed.insert(0, dict(me, _self=True)) - selected = max(0, min(selected, len(listed) - 1)) if listed else 0 - visible = max(1, h - len(rows) - 2) - if selected < scroll: - scroll = selected - elif selected >= scroll + visible: - scroll = selected - visible + 1 - scroll = max(0, min(scroll, max(0, len(listed) - visible))) - - for idx in range(scroll, min(len(listed), scroll + visible)): - p = listed[idx] - if len(rows) >= h - 2: - break - mine = bool(p.get("_self")) - up = True if mine else bool(p.get("Online")) - here = idx == selected - tint = bg(28, 44, 62) if here else "" - path_direct = bool(p.get("CurAddr")) - # "this" rather than DIRECT or a relay name: the path column - # answers how the traffic gets there, and for this machine it - # does not go anywhere. - path = "this" if mine else ( - "DIRECT" if path_direct else (p.get("Relay") or "?")) - name = peer_name(p) - line = [(tint + (ACCENT if mine else (ONLINE if up else OFFLINE)), - ("▸" if here else " ") - + "%s " % ("◆" if mine else ("●" if up else "○"))), - (tint + (TXT if up else OFFLINE), - pad(name[:namew - 1], namew)), - (tint + DIM, "%-8s" % (p.get("OS") or "?")[:8]), - (tint + (ACCENT if mine else - (DIRECT if path_direct else RELAY)), - "%-7s" % (path if up else "-"))] - if wide: - line.append((tint + DIM, " %5s %5s" % (human(p.get("RxBytes")), - human(p.get("TxBytes"))))) - line.append((tint + DIM, " %5s" % (seen(p.get("LastSeen")) - if not up else "now"))) - if p.get("PrimaryRoutes"): - line.append((tint + ROUTE, " ⇄")) - if here: - line.append((tint, " " * w)) - rows.append(seg(line, w - 1)) - - while len(rows) < h - 2: - rows.append("") - if routers: - first = routers[0] - rts = first.get("PrimaryRoutes") or [] - rows.append(seg([(ROUTE, " ⇄ "), (DIM, "%s routes " % first.get("HostName")), - (TXT, ", ".join(rts[:2])), - (DIM, (" +%d more" % (len(rts) - 2)) if len(rts) > 2 else "")], - w - 1)) - hints = [[(ACCENT, "↑↓"), (DIM, " select")], - [(DIM, "↵/[i]nfo")], [(DIM, "[c]opy")], [(DIM, "[g]raph")], - [(DIM, "[o]ffline")], [(DIM, "[n]=%gs" % REFRESH)], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]] - for line in pack_hints(hints, w - 2): - rows.append(" " + line) - draw(rows, w, h) - time.sleep(0.3) - - -main() diff --git a/usage.py b/usage.py deleted file mode 100755 index 998702d..0000000 --- a/usage.py +++ /dev/null @@ -1,3607 +0,0 @@ -#!/usr/bin/env python3 -# terminal-toys - small dependency-free terminal widgets -# Copyright (C) 2026 William Li -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""How much the coding agents on this machine have been used. - -One tab per agent, because they do not agree on what usage even means: one -counts tokens, another counts lines it wrote, and several publish nothing at -all outside their own session. A single table would need a shared schema that -does not exist, so each tab shows that agent's own shape - and an agent that -exposes nothing says so rather than showing a plausible zero. - - python3 usage.py [-n SECONDS] - -Most of this is read from local state files. The exception is the remaining -quota, which no agent writes to disk in a current form: Claude, Codex, Cursor -and Copilot each publish one over an endpoint, fetched with the credential -that agent already holds and sent only to that agent's own host. Nothing here is -inferred from a number that was not published. - -Keys: left/right or tab switch agent, up/down scroll it, pgup/pgdn by -the page, home/end to either edge, r refreshes now, q quits. -""" -import datetime -import json -import os -import glob -import re -import shutil -import sqlite3 -import sys -import urllib.error -import urllib.request -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from common import (RST, Keyboard, bg, draw, heat, load_config, maybe_help, mix, - meter, pack_hints, pad, rgb, seg, setup, size, spread, - stacked_bar, title, vbars) - -_CFG = load_config("usage", { - # Empty discovers whatever this machine has, which is the default and - # what most people want. Naming agents instead pins the set and its order - # - listing one is how you say "keep the tab even though it is not - # installed yet", and it is also how you turn discovery off. The same - # empty-means-discover idiom as github.accounts and linear.exclude_teams. - "agents": [], - "exclude_agents": [], - # US$ per million tokens, keyed by model. Empty by design - see RATES. - "rates": {}, - # What each subscription costs per month, keyed by agent. Nothing to - # ship: Anthropic lists Max as "from $100" because it varies by tier, - # and nobody's invoice is on this machine. Set it and the metered - # section can say what the plan saved. - "plan_cost": {}, - "refresh": 30, -}) - -REFRESH = float(_CFG["refresh"]) -# US$ per million tokens, keyed by model. Nothing is shipped here on purpose: -# only Cursor publishes what it charges, so every other agent's "what would -# this have cost" is arithmetic on a rate card the user supplies. A price this -# repo invented would be exactly the fabricated denominator it exists to avoid. -RATES = _CFG["rates"] or {} -PLAN_COST = _CFG["plan_cost"] or {} -RATE_KINDS = ("input", "output", "cache_read", "cache_write", - "cache_write_1h") - -# Anthropic's published list prices, US$ per million tokens, copied from -# platform.claude.com/docs/en/docs/about-claude/pricing on the date below. -# They are shipped because they are published facts with a citable source, -# not a guess - but they go stale silently, so the date is carried onto the -# screen with them and config overrides any line. -# -# Cache writes come in two durations at different prices - 1.25x input for a -# five-minute write, 2x for an hour - and the transcripts record which was -# taken, per iteration, so both are carried and neither is assumed. -LIST_RATES_AS_OF = "Aug 2026" -LIST_RATES_SOURCE = "platform.claude.com and developers.openai.com pricing" -# Models known to have no published price: prefix matching would otherwise -# hand gpt-5.3-codex-spark its family's rate, and Spark is explicitly not on -# the API (supported_in_api: false in Codex's own model cache). Naming them -# here makes them report as unpriced rather than as a number nobody published. -NO_PUBLISHED_PRICE = ("gpt-5.3-codex-spark", "codex-auto-review") - -# OpenAI's published list prices, from developers.openai.com/api/docs/pricing -# on the same date. OpenAI does not charge for cache writes, so there is no -# cache_write entry and one would be wrong rather than merely absent. -LIST_RATES = { - "gpt-5.6-sol": {"input": 5, "output": 30, "cache_read": 0.50}, - "gpt-5.6-terra": {"input": 2, "output": 12, "cache_read": 0.20}, - "gpt-5.6-luna": {"input": 0.20, "output": 1.20, "cache_read": 0.02}, - "gpt-5.5-pro": {"input": 30, "output": 180}, - "gpt-5.5": {"input": 5, "output": 30, "cache_read": 0.50}, - "gpt-5.4-mini": {"input": 0.75, "output": 4.50, "cache_read": 0.075}, - "gpt-5.4-nano": {"input": 0.20, "output": 1.25, "cache_read": 0.02}, - "gpt-5.4-pro": {"input": 30, "output": 180}, - "gpt-5.4": {"input": 2.50, "output": 15, "cache_read": 0.25}, - "gpt-5.3-codex": {"input": 1.75, "output": 14, "cache_read": 0.175}, - "gpt-5.2-pro": {"input": 21, "output": 168}, - "gpt-5.2": {"input": 1.75, "output": 14, "cache_read": 0.175}, - "gpt-5.1": {"input": 1.25, "output": 10, "cache_read": 0.125}, - "gpt-5-mini": {"input": 0.25, "output": 2, "cache_read": 0.025}, - "gpt-5-nano": {"input": 0.05, "output": 0.40, "cache_read": 0.005}, - "gpt-5-pro": {"input": 15, "output": 120}, - "gpt-5": {"input": 1.25, "output": 10, "cache_read": 0.125}, - "claude-fable-5": {"input": 10, "output": 50, "cache_write": 12.50, - "cache_read": 1, - "cache_write_1h": 20}, - "claude-mythos-5": {"input": 10, "output": 50, "cache_write": 12.50, - "cache_read": 1, - "cache_write_1h": 20}, - "claude-opus-5": {"input": 5, "output": 25, "cache_write": 6.25, - "cache_read": 0.50, - "cache_write_1h": 10}, - "claude-opus-4-8": {"input": 5, "output": 25, "cache_write": 6.25, - "cache_read": 0.50, - "cache_write_1h": 10}, - "claude-opus-4-7": {"input": 5, "output": 25, "cache_write": 6.25, - "cache_read": 0.50, - "cache_write_1h": 10}, - "claude-opus-4-6": {"input": 5, "output": 25, "cache_write": 6.25, - "cache_read": 0.50, - "cache_write_1h": 10}, - "claude-opus-4-5": {"input": 5, "output": 25, "cache_write": 6.25, - "cache_read": 0.50, - "cache_write_1h": 10}, - "claude-opus-4-1": {"input": 15, "output": 75, "cache_write": 18.75, - "cache_read": 1.50, - "cache_write_1h": 30}, - "claude-sonnet-5": {"input": 2, "output": 10, "cache_write": 2.50, - "cache_read": 0.20, - "cache_write_1h": 4}, - "claude-sonnet-4-6": {"input": 3, "output": 15, "cache_write": 3.75, - "cache_read": 0.30, - "cache_write_1h": 6}, - "claude-sonnet-4-5": {"input": 3, "output": 15, "cache_write": 3.75, - "cache_read": 0.30, - "cache_write_1h": 6}, - "claude-haiku-4-5": {"input": 1, "output": 5, "cache_write": 1.25, - "cache_read": 0.10, - "cache_write_1h": 2}, - "claude-haiku-3-5": {"input": 0.80, "output": 4, "cache_write": 1, - "cache_read": 0.08, - "cache_write_1h": 1.6}, -} - -OK = rgb(90, 240, 160) -WARN = rgb(255, 200, 90) -BAD = rgb(255, 100, 110) -DIM = rgb(127, 147, 172) -GRID = rgb(60, 78, 98) -TXT = rgb(225, 235, 245) -LBL = rgb(130, 165, 200) -ACCENT = rgb(150, 210, 255) -AGENT = rgb(180, 160, 255) - -# One hue, four steps, the way /stats and the contribution calendar do it. -# heat() runs green to amber to red, which reads as a change of *kind* rather -# than of amount - wrong for "more of the same thing". -# Claude keeps the terracotta of its own /stats. Codex gets a white ramp, so -# two calendars side by side are told apart by hue rather than by reading the -# heading - the steps are the same four, only the colour differs. -HEAT_STEPS = ((74, 52, 46), (140, 78, 58), (196, 100, 66), (240, 132, 84)) -CODEX_STEPS = ((66, 72, 82), (122, 130, 144), (182, 190, 202), (240, 244, 250)) -GROK_STEPS = ((44, 62, 88), (62, 104, 156), (86, 150, 210), (120, 196, 250)) -CURSOR_STEPS = ((48, 74, 66), (72, 124, 104), (100, 172, 142), (140, 220, 184)) -# One hue per provider, for the summary's group headings. Each is the colour -# that agent's own tab already uses - Claude the terracotta of its /stats, -# Codex its white ramp, Grok its blue, Cursor its included-lane green - so the -# same agent looks the same wherever you meet it. Copilot and Antigravity have -# no calendar to borrow from and get their own, chosen to sit clear of the -# amber and red this widget reserves for trouble. -AGENT_HUE = {"claude": (240, 132, 84), "codex": (206, 214, 228), - "cursor": (126, 208, 176), "grok": (120, 196, 250), - "copilot": (186, 166, 255), "antigravity": (232, 158, 200)} -EMPTY_CELL = rgb(58, 66, 80) - - -def shade(frac, steps=HEAT_STEPS): - """Which of the four steps a day falls in.""" - return rgb(*steps[min(3, max(0, int(frac * 3.999)))]) - - -CLAUDE_STATS = os.path.expanduser("~/.claude/stats-cache.json") -CLAUDE_CREDS = os.path.expanduser("~/.claude/.credentials.json") -CLAUDE_CONFIG = os.path.expanduser("~/.claude.json") -CLAUDE_USAGE_API = "https://api.anthropic.com/api/oauth/usage" -CLAUDE_PROFILE_API = "https://api.anthropic.com/api/oauth/profile" -# a plan does not change between refreshes; the windows do -PLAN_TTL = 3600 -# The windows are not stated in limits[], but the same response names them in -# its own top-level keys: five_hour and seven_day. -CLAUDE_WINDOW = {"session": "5h", "weekly": "7d"} -CLAUDE_WINDOW_SECS = {"session": 5 * 3600, "weekly": 7 * 86400} - - -def claude_lane_rank(limit): - """Where a Claude limit belongs in the list, shortest leash first. - - The server returns them in no order worth keeping. Read top to bottom - they should widen: the five-hour session is what stops you this - afternoon, the weekly total is what stops you this week, and a - model-scoped weekly limit stops only one model. - """ - if limit.get("kind") == "session": - return 0 - scoped = ((limit.get("scope") or {}).get("model") or {}).get("display_name") - return 2 if scoped else 1 - - -CODEX_SESSIONS = os.path.expanduser("~/.codex/sessions/**/*.jsonl") -# Recursive on purpose: subagent transcripts live a further two levels down, -# in //subagents/, and that is where Haiku and most of -# Sonnet actually run. Globbing one level deep found 38 files of 520MB and -# silently missed 257 of them. -CLAUDE_TRANSCRIPTS = os.path.expanduser("~/.claude/projects/**/*.jsonl") -RATE_FILES = 3 # newest transcripts to sample for a rate -MIN_GAP = 1.0 # seconds; below this the timestamps are not a turn -ANTIGRAVITY_DIR = os.path.expanduser("~/.gemini/antigravity-cli") -ANTIGRAVITY_TOKEN = os.path.join(ANTIGRAVITY_DIR, "antigravity-oauth-token") -ANTIGRAVITY_CONVERSATIONS = os.path.join(ANTIGRAVITY_DIR, "conversations/*.db") -ANTIGRAVITY_HISTORY = os.path.join(ANTIGRAVITY_DIR, "history.jsonl") -CODE_ASSIST_API = ("https://cloudcode-pa.googleapis.com" - "/v1internal:loadCodeAssist") -COPILOT_DB = os.path.expanduser("~/.copilot/session-store.db") -COPILOT_CONFIG = os.path.expanduser("~/.copilot/config.json") -COPILOT_USER_API = "https://api.github.com/copilot_internal/user" -TAIL = 256 * 1024 # enough to reach the last token_count in a rollout -CURSOR_DB = os.path.expanduser("~/.cursor/ai-tracking/ai-code-tracking.db") -# One hue per lane, as cursor-agent's own Usage view does it. These are -# categories rather than one gauge, so a green-to-red severity ramp would -# imply a relationship between them that does not exist - and each bar is -# labelled and carries its own percentage, so the colour is decoration. -# Three tints of Cursor's own colour rather than three unrelated hues. They -# are still categories, not a severity ramp, so they stay distinguishable - -# but they now read as Cursor's, which three borrowed colours never did. -CURSOR_LANE_STOPS = (("included", 1.0), ("auto", 0.80), ("api", 0.62)) -GROK_SESSIONS = os.path.expanduser("~/.grok/sessions/**/updates.jsonl") -# the quota is not in the session transcripts: it arrives on the client log -GROK_LOG = os.path.expanduser("~/.grok/logs/unified.jsonl") -CURSOR_AUTH = os.path.expanduser("~/.config/cursor/auth.json") -CURSOR_RPC = "https://api2.cursor.sh/aiserver.v1.DashboardService/%s" -CURSOR_USAGE_API = CURSOR_RPC % "GetCurrentPeriodUsage" - - -def span_ms(ms): - """A duration in milliseconds as days, hours and minutes.""" - s = int((ms or 0) / 1000) - d, s = divmod(s, 86400) - h, s = divmod(s, 3600) - m = s // 60 - if d: - return "%dd %dh %dm" % (d, h, m) - if h: - return "%dh %dm" % (h, m) - # a couple of seconds of generation is not "0m" - return "%dm" % m if m else "%.1fs" % ((ms or 0) / 1000.0) - - -def big_num(n): - """Token counts run to billions; nobody reads eleven digits.""" - n = float(n or 0) - for unit, size in (("B", 1e9), ("M", 1e6), ("k", 1e3)): - if abs(n) >= size: - return "%.1f%s" % (n / size, unit) - return "%d" % n - - -def ago(when): - if not when: - return "never" - s = time.time() - when - if s < 60: - return "%ds" % int(s) - if s < 3600: - return "%dm" % int(s // 60) - if s < 86400: - return "%dh" % int(s // 3600) - if s < 365 * 86400: - return "%dd" % int(s // 86400) - # a subscription can be years old, and "890d" is not a span anyone reads - return "%.1fy" % (s / (365.0 * 86400)) - - -def claude_live(): - """Rate-limit utilization, live from the endpoint /usage itself calls. - - The OAuth token is the one Claude Code already holds. It goes only to - Anthropic, is never printed, and an expired one is not used at all: the - refresh token sits beside it, but spending it would race Claude Code's - own credential handling for a number that has a local cache anyway. - """ - try: - with open(CLAUDE_CREDS) as f: - o = (json.load(f) or {}).get("claudeAiOauth") or {} - except (OSError, ValueError): - return None - tok = o.get("accessToken") - if not tok or (o.get("expiresAt") or 0) / 1000.0 <= time.time(): - return None - req = urllib.request.Request(CLAUDE_USAGE_API, headers={ - "Authorization": "Bearer " + tok, "User-Agent": "terminal-toys"}) - try: - with urllib.request.urlopen(req, timeout=20) as r: - return {"u": json.load(r), "source": "live", "at": time.time(), - "plan": o.get("subscriptionType") or ""} - except (urllib.error.URLError, ValueError, OSError): - return None - - -def claude_profile(): - """Which subscription the windows belong to. - - A separate endpoint from the usage one, and near-static, so it is held - far longer - a plan does not change between refreshes, and the usage - endpoint answers 429 if these are called at usage cadence. - """ - try: - with open(CLAUDE_CREDS) as f: - o = (json.load(f) or {}).get("claudeAiOauth") or {} - except (OSError, ValueError): - return None - tok = o.get("accessToken") - if not tok or (o.get("expiresAt") or 0) / 1000.0 <= time.time(): - return None - req = urllib.request.Request(CLAUDE_PROFILE_API, headers={ - "Authorization": "Bearer " + tok, "User-Agent": "terminal-toys"}) - try: - with urllib.request.urlopen(req, timeout=20) as r: - d = json.load(r) - except (urllib.error.URLError, ValueError, OSError): - return None - d["_plan"] = o.get("subscriptionType") or "" - return d - - -def claude_creds_plan(): - """What the credentials file alone can say about the plan. - - The profile endpoint is richer, but this needs no network and is always - there, so the section degrades to two true lines instead of vanishing. - """ - try: - with open(CLAUDE_CREDS) as f: - o = (json.load(f) or {}).get("claudeAiOauth") or {} - except (OSError, ValueError): - return None - if not o.get("subscriptionType"): - return None - return {"_plan": o["subscriptionType"], "_local": True, - "organization": {"rate_limit_tier": o.get("rateLimitTier") or ""}} - - -def claude_plan_rows(prof, w): - org = (prof.get("organization") or {}) - pairs = [] - since = iso_epoch(org.get("subscription_created_at") or "") - day = iso_day(org.get("subscription_created_at") or "") - if since and day: - pairs.append(("member since", "%s · %s ago" % (day, ago(since)))) - if org.get("subscription_status"): - pairs.append(("status", org["subscription_status"])) - if org.get("rate_limit_tier"): - pairs.append(("rate limit tier", org["rate_limit_tier"])) - if org.get("billing_type"): - pairs.append(("billing", org["billing_type"].replace("_", " "))) - return plan_rows(prof.get("_plan") or org.get("organization_type"), - pairs, w, - note="from credentials" if prof.get("_local") else "") - - -def claude_stale(): - """What Claude Code last fetched, for when the live call cannot run. - - It is a cache with a timestamp, so it is shown with its age - and a - window whose reset has already gone by is said to have passed rather - than counted down to, because a stale five-hour window describes a - period that has ended. - """ - try: - with open(CLAUDE_CONFIG) as f: - c = (json.load(f) or {}).get("cachedUsageUtilization") or {} - except (OSError, ValueError): - return None - if not c.get("utilization"): - return None - return {"u": c["utilization"], "source": "cached", - "at": (c.get("fetchedAtMs") or 0) / 1000.0, "plan": ""} - - -def read_claude(): - """Claude Code's own stats cache, plus what is left of the limits. - - The cache records what was spent - tokens, messages, sessions - and - carries no limit at all. The remaining quota is a separate reading - entirely, and account-wide rather than about this machine. - """ - quota = cached("claude", lambda: claude_live() or claude_stale()) - profile = (cached("claude-plan", claude_profile, ttl=PLAN_TTL) - or claude_creds_plan()) - try: - stat = os.stat(CLAUDE_STATS) - with open(CLAUDE_STATS) as f: - d = json.load(f) - except (OSError, ValueError) as e: - return {"ok": False, "why": "%s" % type(e).__name__, - "quota": quota, "profile": profile} - rates, sampled = claude_rates() - return {"ok": True, "mtime": stat.st_mtime, "data": d, "quota": quota, - "profile": profile, "rates": rates, "sampled": sampled, - "daily_models": claude_daily()} - - -def iso_epoch(s): - """ISO-8601 to epoch seconds. - - fromisoformat rejects a trailing Z before Python 3.11, and these APIs mix - Z with +00:00 in the same response. A value carrying no zone at all is - read as local time, which is why the callers here pass zoned strings. - """ - try: - # Go writes nanoseconds; fromisoformat takes 3 or 6 digits, not 9 - s = re.sub(r"(\.\d{6})\d+", r"\1", re.sub(r"Z$", "+00:00", s)) - return datetime.datetime.fromisoformat(s).timestamp() - except (ValueError, TypeError, AttributeError): - return None - - -def iso_day(s): - """A date, kept in the zone it arrived in. - - assigned_date carries the account's own offset. Converting it to this - machine's zone can move it a day - 3 Jun at 12:10 -07:00 is 4 Jun in UTC - - and then the pane disagrees with what GitHub shows for the same seat. - """ - try: - d = datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)) - except (ValueError, TypeError, AttributeError): - return "" - return d.strftime("%-d %b %Y") - - -def scope_phrase(w, used): - """The scope note, shortened before it can push a reset off the line. - - "not this machine" is the point of the sentence, but it is also sixteen - characters, and seg() clips whatever runs past the pane. Losing the - clause leaves a shorter true line; losing the end of "resets in 15d" - leaves "resets in 1", which is a different and wrong number. - """ - full = " · account-wide, not this machine " - return full if used + len(full) <= w - 1 else " · account-wide " - - -PACE_FLOOR = 3.0 # below this much of a window, the figure is noise - - -def lead(pct_used, window_secs, reset_ts): - """How far ahead of the clock a quota is, as a signed percentage. - - The share of the window already gone minus the share of the allowance - already spent. Positive is headroom - CodexBar calls the same quantity - "in reserve" - and negative means this runs out before the window does. - - Note the sign is the opposite of CodexBar's separate pace token, where - +X% means burning too fast. The cushion reading is the one that matches - the phrase, so the column is labelled rather than left to be guessed. - - Nothing is fetched for it: the window length and the reset are already - on screen. Below PACE_FLOOR of a window it is not shown at all, because - ten minutes into a week every number looks like a catastrophe or a - triumph. CodexBar gates it the same way and for the same reason. - """ - if not window_secs or not reset_ts: - return None - left = reset_ts - time.time() - gone = window_secs - left - if gone <= 0 or gone > window_secs: - return None - elapsed = 100.0 * gone / window_secs - if elapsed < PACE_FLOOR: - return None - return elapsed - (pct_used or 0) - - -def ahead_of(value): - """True when a pace figure says ahead, False when behind, None if unknown.""" - return None if value is None else value >= 0 - - -def pct_colour(pct, ahead, hue): - """What colour a quota's percentage is written in. - - The agent's own colour, so the number matches the bar it sits beside - rather than being tinted by a gradient that made every figure faintly - warm and said nothing about time. - - Red is the one exception, at 90% spent, because nearly empty is trouble - whatever the pace. Behind-the-clock deliberately does *not* colour this: - the pace cell beside it is already amber for exactly that, and a number - and its own explanation both turning yellow reads as two problems. - """ - if pct >= 90: - return BAD - return rgb(*hue) if hue else heat(pct / 100.0) - - -def pct_text(pct): - """A percentage with enough precision to prove it is not a placeholder. - - Every Antigravity lane rounded to "0%" - which is what an empty section - looks like - while one of them was genuinely 0.4% spent and another - 0.03%. A real small number and no number at all have to be tellable - apart, so below 10% the figure keeps a decimal, and below 1% it keeps - two. A true zero stays a bare 0%. - """ - pct = float(pct or 0) - if pct <= 0: - return " 0%" - if pct < 1: - return "%5.2f%%" % pct - if pct < 10: - return "%5.1f%%" % pct - return "%5.0f%%" % pct - - -def pace_cell(value): - """The signed cushion, coloured by whether it is one.""" - if value is None: - return (DIM, "") - return (OK if value >= 0 else WARN, " %+.0f%%" % value) - - -def quota_window(reset_ts): - """The span a monthly quota covers, worked back from its reset. - - Copilot states when the quota resets but never how long the window is. - It can be derived, but only safely when the reset lands on midnight UTC - on the first of a month - which is what a calendar-month cycle looks - like, and what this account shows. Anything else and the window is not - known, so nothing is claimed about it. - """ - if not reset_ts: - return None - end = datetime.datetime.fromtimestamp(reset_ts, datetime.timezone.utc) - if (end.day, end.hour, end.minute, end.second) != (1, 0, 0, 0): - return None - start = (end.replace(year=end.year - 1, month=12) if end.month == 1 - else end.replace(month=end.month - 1)) - return "%s → %s" % (start.strftime("%-d %b"), end.strftime("%-d %b")) - - -def left_span(secs): - d, r = divmod(int(secs), 86400) - h, r = divmod(r, 3600) - if d: - return "%dd %dh" % (d, h) - return "%dh %dm" % (h, r // 60) if h else "%dm" % (r // 60) - - -def claude_quota(q, w): - """The windows Claude Code's own /usage shows. - - Read from limits[], which is the server's own curated list: the rest of - the response carries a dozen null pools with names like nimbus_quill and - iguana_necktie that /usage does not render either. Each entry names - itself, so a model-scoped weekly limit arrives labelled Fable or Opus - without this having to know either name. - """ - if not q: - return [] - u = q.get("u") or {} - lanes = sorted((l for l in (u.get("limits") or []) - if l.get("percent") is not None), key=claude_lane_rank) - if not lanes: - return [] - live = q.get("source") == "live" - src = "live" if live else "cached %s ago" % ago(q.get("at")) - plan = q.get("plan") or "" - rows = [seg([(LBL, " ── QUOTA ── "), (OK if live else WARN, src), - (DIM, scope_phrase(w, 13 + len(src) + len(plan))), - (DIM, plan)], w - 1)] - - def label(l): - scope = ((l.get("scope") or {}).get("model") or {}).get("display_name") - group = l.get("group") or "" - name = scope or ("overall" if l.get("kind") == "weekly_all" - else group or l.get("kind") or "?") - return ("%s %s" % (name, CLAUDE_WINDOW.get(group, ""))).strip() - - texts = [label(l) for l in lanes] - label_w = max(9, max(len(t) for t in texts)) - for l, text in zip(lanes, texts): - pct = float(l.get("percent") or 0) - used = max(0.0, min(1.0, pct / 100.0)) - ts = iso_epoch(l.get("resets_at")) - when = "" - if ts: - left = ts - time.time() - when = ("resets in " + left_span(left) if left > 0 - else "resetting" if live else "already reset") - sev = (l.get("severity") or "normal").lower() - window = CLAUDE_WINDOW_SECS.get(l.get("group") or "") - # is_active marks the limit currently doing the binding - the one that - # will stop you first - so it is the one worth reading brightly. - rows.append(seg([(TXT if l.get("is_active") else DIM, - " " + pad(text, label_w) + " ")] - + paced_bar(used, elapsed_of(window, ts), - max(8, w - 35 - label_w), - AGENT_HUE["claude"]) - + [(pct_colour(pct, ahead_of(lead(pct, window, ts)), AGENT_HUE["claude"]), - pct_text(pct)), - pace_cell(lead(pct, window, ts)), - (BAD if sev not in ("normal", "") else DIM, - " " + (when if sev in ("normal", "") - else "%s · %s" % (sev, when)))], w - 1)) - extra = u.get("extra_usage") or {} - spend = u.get("spend") or {} - if extra.get("is_enabled") and spend.get("limit"): - def money(m): - return "%.2f" % ((m.get("amount_minor") or 0) / - (10.0 ** (m.get("exponent") or 2))) - rows.append(seg([(DIM, " extra usage "), - (TXT, money(spend.get("used") or {})), - (DIM, " of "), (TXT, money(spend["limit"])), - (DIM, " " + ((spend["limit"].get("currency")) or "")), - (DIM, " monthly")], w - 1)) - rows.append("") - return rows - - -_TRANSCRIPT_CACHE = {} - - -def usage_kinds(u): - """Token counts from one transcript usage block, by priced kind. - - A block's top-level numbers can all be zero while its `iterations` carry - the real figures, so the iterations win where they exist. Cache writes - are split by duration because they are priced differently, and the flat - cache_creation_input_tokens is only used when that split is absent. - """ - out = dict.fromkeys(RATE_KINDS, 0) - for x in (u.get("iterations") or [u]): - out["input"] += x.get("input_tokens") or 0 - out["output"] += x.get("output_tokens") or 0 - out["cache_read"] += x.get("cache_read_input_tokens") or 0 - split = x.get("cache_creation") or {} - if split: - out["cache_write"] += split.get("ephemeral_5m_input_tokens") or 0 - out["cache_write_1h"] += split.get("ephemeral_1h_input_tokens") or 0 - else: - out["cache_write"] += x.get("cache_creation_input_tokens") or 0 - return out - - -def scan_transcript(path): - """Per-record token counts from one transcript, keyed by record uuid. - - Keyed rather than summed because the same message appears in more than - one file: resuming or forking a session replays its history into the new - transcript, and subagent turns are written twice over. Left raw that - inflated Fable by 29% and Opus 5 by 13% against Claude Code's own totals; - de-duplicated on uuid they land within a point. - - Cached on (mtime, size): the corpus here is 520MB across 295 files and a - finished transcript never changes, so each is parsed once. - """ - try: - st = os.stat(path) - except OSError: - return {} - key = (st.st_mtime, st.st_size) - hit = _TRANSCRIPT_CACHE.get(path) - if hit and hit[0] == key: - return hit[1] - records = {} - try: - with open(path, errors="replace") as fh: - for line in fh: - if '"usage"' not in line: - continue - try: - r = json.loads(line) - except ValueError: - continue - msg = r.get("message") or {} - u = msg.get("usage") - model = msg.get("model") - uid = r.get("uuid") - stamp = r.get("timestamp") - if not u or not model or not stamp or not uid: - continue - when = iso_epoch(stamp) - if not when: - continue - got = usage_kinds(u) - if not any(got.values()): - continue - records[uid] = (datetime.date.fromtimestamp(when).isoformat(), - model, got) - except OSError: - return {} - _TRANSCRIPT_CACHE[path] = (key, records) - return records - - -def claude_daily(): - """Every transcript's per-day, per-model tokens, de-duplicated. - - stats-cache.json has dailyModelTokens, but only one total per model per - day - and input, output and the two cache kinds differ in price by up to - fifty times, so a total cannot be costed. The transcripts carry the - split, which is why the money comes from here and not from the cache. - """ - seen = {} - for path in glob.glob(CLAUDE_TRANSCRIPTS, recursive=True): - seen.update(scan_transcript(path)) - merged = {} - for day, model, tokens in seen.values(): - bucket = merged.setdefault(day, {}).setdefault( - model, dict.fromkeys(RATE_KINDS, 0)) - for kind in RATE_KINDS: - bucket[kind] += tokens[kind] - return merged - - -def window_models(daily, days): - """Per-model token totals over the last N days (1 = today only).""" - today = datetime.date.today() - first = (today - datetime.timedelta(days=days - 1)).isoformat() - out = {} - for day, models in (daily or {}).items(): - if day < first: - continue - for model, tokens in models.items(): - bucket = out.setdefault(model, dict.fromkeys(RATE_KINDS, 0)) - for kind in RATE_KINDS: - bucket[kind] += tokens.get(kind) or 0 - return sorted(out.items()) - - -def claude_rates(): - """Output tokens per second, from the newest transcripts. - - A turn is a `user` record followed by an `assistant` one, and the rate is - that assistant's output tokens over the gap between them. Measuring from - *any* previous record instead inflates it wildly - two assistant records - can be milliseconds apart while the second reports a whole turn's output - - and even with the right boundary a few gaps are impossible, 1073 tokens in - 0.07s among them, where the timestamps plainly do not bracket generation. - - So the median is what gets shown. It sits at 74-75 whichever way the - outliers are trimmed, which is the reason to trust it; the maximum moves - from 15328 to 800 on the same data, which is the reason not to show one. - """ - files = sorted(glob.glob(CLAUDE_TRANSCRIPTS, recursive=True), - key=os.path.getmtime, reverse=True)[:RATE_FILES] - out, sampled = [], 0 - for path in files: - prev, prev_type = None, None - lines = tail_lines(path, 4 * 1024 * 1024) - sampled += 1 - for line in lines: - if '"timestamp"' not in line: - continue - try: - d = json.loads(line) - except ValueError: - continue - ts, typ = d.get("timestamp"), d.get("type") - if (typ == "assistant" and ts and prev and prev_type == "user" - and not d.get("isAbortedMidStream")): - tok = ((d.get("message") or {}).get("usage") - or {}).get("output_tokens") or 0 - if tok: - try: - a = datetime.datetime.fromisoformat( - prev.replace("Z", "+00:00")) - b = datetime.datetime.fromisoformat( - ts.replace("Z", "+00:00")) - except ValueError: - prev, prev_type = ts, typ - continue - gap = (b - a).total_seconds() - if MIN_GAP <= gap < 300: - out.append(tok / gap) - if ts: - prev, prev_type = ts, typ - out.sort() - return out, sampled - - -def cursor_live(): - """Plan usage, from the endpoint cursor-agent's own Usage view calls. - - Not the documented cursor.com dashboard API - that one wants a browser - cookie and returns 401 to anything this machine has. The CLI talks Connect - to aiserver.v1.DashboardService with the bearer token it stores in - ~/.config/cursor/auth.json, which is the credential this reuses. - - Undocumented and versioned only by the CLI bundle it was read out of, so - every failure is silent and the tab simply falls back to authorship. - """ - try: - with open(CURSOR_AUTH) as f: - tok = json.load(f).get("accessToken") - except (OSError, ValueError): - return None - if not tok: - return None - return cursor_rpc("GetCurrentPeriodUsage", {}, tok) - - -def cursor_token(): - try: - with open(CURSOR_AUTH) as f: - return json.load(f).get("accessToken") - except (OSError, ValueError): - return None - - -def cursor_rpc(method, body, tok=None): - tok = tok or cursor_token() - if not tok: - return None - req = urllib.request.Request(CURSOR_RPC % method, - data=json.dumps(body).encode(), headers={ - "Authorization": "Bearer " + tok, - "Content-Type": "application/json", - "Connect-Protocol-Version": "1", - "User-Agent": "terminal-toys"}) - try: - with urllib.request.urlopen(req, timeout=25) as r: - return json.load(r) - except (urllib.error.URLError, ValueError, OSError): - return None - - -def cursor_plan(): - """Which Cursor plan the percentages are percentages of. - - GetPlanInfo is where the plan's name and price live - the usage call - carries neither, and its $400 limit is meaningless without knowing that - is what Ultra includes. Found in the CLI bundle the same way the usage - endpoint was. - """ - return cursor_rpc("GetPlanInfo", {}) - - -def cursor_plan_rows(info, w): - plan = (info or {}).get("planInfo") or {} - if not plan: - return [] - pairs = [] - if plan.get("price"): - pairs.append(("price", plan["price"])) - inc = plan.get("includedAmountCents") - if inc: - pairs.append(("included", "$%.2f per cycle" % (int(inc) / 100.0))) - owner = (plan.get("planOwner") or "").replace("PLAN_OWNER_", "").lower() - if owner: - pairs.append(("billing", owner)) - return plan_rows(plan.get("planName"), pairs, w) - - -def cursor_spend(days=30): - """Per-model tokens and real cost over a window. - - This is what the plan percentages are made of: which model spent the - money. `totalCents` is Cursor's own figure, not an estimate. - """ - now = int(time.time() * 1000) - return cursor_rpc("GetAggregatedUsageEvents", - {"startDate": str(now - days * 86400 * 1000), - "endDate": str(now)}) - - -CURSOR_EVENT_PAGE = 1000 # the RPC's own ceiling per request -CURSOR_EVENT_PAGES = 8 # enough to reach past any sane window -EVENTS_TTL = 1800 - - -def cursor_events(days=30): - """Per-day spend, from the raw events cursor.com's own dashboard uses. - - GetAggregatedUsageEvents totals by model and carries no timestamp at all, - so no calendar can be built from it. GetFilteredUsageEvents returns the - individual events - each with a timestamp, a model and its cents - newest - first, a thousand at a time. - - Paging stops as soon as a page reaches past the window, so the cost is - proportional to the window rather than to the account's whole history: - thirty days is five pages and about eleven seconds here. Held for half an - hour, because that is far too slow to repeat on a redraw. - """ - cut = time.time() - days * 86400 - days_cents, days_tokens, by_day_model = {}, {}, {} - vendor_cents = 0.0 - tokens = 0 - counted = 0 - for page in range(1, CURSOR_EVENT_PAGES + 1): - got = cursor_rpc("GetFilteredUsageEvents", - {"page": page, "pageSize": CURSOR_EVENT_PAGE}) - events = (got or {}).get("usageEventsDisplay") or [] - if not events: - break - oldest = time.time() - for e in events: - try: - when = int(e["timestamp"]) / 1000.0 - except (KeyError, ValueError, TypeError): - continue - oldest = min(oldest, when) - if when < cut: - continue - use = e.get("tokenUsage") or {} - cents = float(use.get("totalCents") or 0) - n = int(use.get("inputTokens") or 0) + int(use.get("outputTokens") or 0) - day = datetime.date.fromtimestamp(when) - days_cents[day] = days_cents.get(day, 0.0) + cents - days_tokens[day] = days_tokens.get(day, 0) + n - slot = by_day_model.setdefault(day.isoformat(), {}).setdefault( - str(e.get("model") or "?"), {"cents": 0.0, "tokens": 0}) - slot["cents"] += cents - slot["tokens"] += n - vendor_cents += cents - tokens += n - counted += 1 - if oldest < cut or len(events) < CURSOR_EVENT_PAGE: - break - if not counted: - return None - return {"by_day": days_cents, "tokens_by_day": days_tokens, - "by_day_model": by_day_model, "vendor_cents": vendor_cents, - "tokens": tokens, "events": counted, "days": days} - - -def read_cursor(): - """Cursor's AI code tracking: how much code it wrote, not what it cost.""" - if not os.path.exists(CURSOR_DB): - return {"ok": False, "why": "no tracking database", - "live": cached("cursor", cursor_live), - "plan": cached("cursor-plan", cursor_plan, ttl=PLAN_TTL), - "events": cached("cursor-events", cursor_events, - ttl=EVENTS_TTL), - "spend": cached("cursor-spend", cursor_spend)} - try: - con = sqlite3.connect("file:%s?mode=ro" % CURSOR_DB, uri=True) - rows = con.execute( - "select count(*), count(distinct conversationId)," - " count(distinct model) from ai_code_hashes").fetchone() - by_model = con.execute( - "select model, count(*) from ai_code_hashes" - " group by model order by 2 desc limit 8").fetchall() - commits = con.execute( - "select count(*), sum(linesAdded), sum(humanLinesAdded)" - " from scored_commits").fetchone() - recent = con.execute( - "select max(timestamp) from ai_code_hashes").fetchone()[0] - con.close() - except sqlite3.Error as e: - return {"ok": False, "why": str(e)[:40]} - return {"ok": True, "live": cached("cursor", cursor_live), - "plan": cached("cursor-plan", cursor_plan, ttl=PLAN_TTL), - "events": cached("cursor-events", cursor_events, - ttl=EVENTS_TTL), - "spend": cached("cursor-spend", cursor_spend), - "hashes": rows[0], "conversations": rows[1], - "models": rows[2], "by_model": by_model, - "commits": commits[0] or 0, "lines": commits[1] or 0, - "human_lines": commits[2] or 0, "last": recent} - - -def tail_lines(path, size=TAIL): - """The last `size` bytes as lines, for files that run to tens of MB. - - A rollout carries its running total on every token_count event, so the - newest one is all that is needed - reading 30MB per refresh to learn a - number that is repeated at the end would be daft. - """ - try: - with open(path, "rb") as f: - f.seek(0, 2) - end = f.tell() - f.seek(max(0, end - size)) - return f.read().decode("utf-8", "ignore").split("\n") - except OSError: - return [] - - -CODEX_AUTH = os.path.expanduser("~/.codex/auth.json") -CODEX_USAGE_API = "https://chatgpt.com/backend-api/wham/usage" -_CODEX_CACHE = {} - -LIVE_TTL = 120 -_LIVE = {} - - -def cached(key, fn, ttl=LIVE_TTL): - """Hold a reading for a while, but never hold a failure that long. - - The pane redraws every 30 seconds; these windows move over hours, and - three tabs between them were making six requests a minute to say the - same thing. A failure is cached too, so a dead endpoint is retried - occasionally rather than on every frame - but only ever for the short - interval, never the long one. A subscription is held an hour because it - does not change; one transient 429 should not blank its section for an - hour, which is exactly what happened here. - """ - now = time.time() - hit = _LIVE.get(key) - if hit and now - hit[0] < hit[2]: - return hit[1] - val = fn() - _LIVE[key] = (now, val, ttl if val else min(ttl, LIVE_TTL)) - return val - - -def codex_live(): - """Account-wide quota, live from the same endpoint the Codex CLI uses. - - The rollouts carry a rate_limits snapshot, but only from whenever Codex - last ran - it can be days stale. This is the current figure, and it is the - account rather than this machine. - - The token comes from ~/.codex/auth.json and goes to the same host Codex - itself talks to; it is never printed. Any failure falls back to the - snapshot, so an expired token costs nothing but freshness. - - Found by reading how CodexBar does it (github.com/steipete/CodexBar), - which documents this endpoint. - """ - try: - with open(CODEX_AUTH) as f: - auth = json.load(f) - except (OSError, ValueError): - return None - tok = (auth.get("tokens") or {}).get("access_token") or auth.get("access_token") - if not tok: - return None - req = urllib.request.Request(CODEX_USAGE_API, headers={ - "Authorization": "Bearer " + tok, "User-Agent": "terminal-toys"}) - try: - with urllib.request.urlopen(req, timeout=20) as r: - return json.load(r) - except (urllib.error.URLError, ValueError, OSError): - return None - - -def scan_rollout(path): - """Per-day tokens, the running total, and the newest quota snapshot. - - Cached on (mtime, size): a finished rollout never changes, and some run to - 30MB, so the full parse happens once per file rather than every refresh. - """ - try: - st = os.stat(path) - except OSError: - return None - key = (st.st_mtime, st.st_size) - hit = _CODEX_CACHE.get(path) - if hit and hit[0] == key: - return hit[1] - daily = {} - total, limits, limits_at = None, None, None - try: - with open(path, errors="ignore") as f: - for line in f: - if '"token_count"' not in line: - continue - try: - d = json.loads(line) - except ValueError: - continue - info = (d.get("payload") or {}).get("info") or {} - last = info.get("last_token_usage") or {} - ts = d.get("timestamp") or "" - if last.get("total_tokens") and ts[:10]: - daily[ts[:10]] = daily.get(ts[:10], 0) + last["total_tokens"] - if info.get("total_token_usage"): - total = info["total_token_usage"] - rl = d.get("payload", {}).get("rate_limits") or info.get("rate_limits") - if rl: - limits, limits_at = rl, ts - except OSError: - return None - out = {"daily": daily, "total": total, "limits": limits, - "limits_at": limits_at, "mtime": st.st_mtime} - _CODEX_CACHE[path] = (key, out) - return out - - -_ROLLOUT_MODELS = {} - - -def scan_rollout_models(path): - """Per-day, per-model token counts from one rollout. - - The model is not on the token counts: it arrives in `turn_context`, one - per turn, and applies to the `token_count` events that follow it. So the - file is walked in order, carrying the model forward. - - `last_token_usage` is the per-turn delta - the running total is on every - event and summing those would count the session once per turn. Within - input_tokens, cached_input_tokens is the cheaper subset, and within - output_tokens the reasoning tokens are already included, so only the - uncached remainder is charged at the input rate. - - Keyed by (session, ordinal) so a rollout replayed into a resumed session - is not counted twice - the same fault that inflated Claude's figures. - """ - try: - st = os.stat(path) - except OSError: - return {} - key = (st.st_mtime, st.st_size) - hit = _ROLLOUT_MODELS.get(path) - if hit and hit[0] == key: - return hit[1] - # session_id, not the filename: a resumed session replays into a new - # file, and two rollouts in different directories can share a basename. - # The sequence number is counted here because `ordinal` is absent from - # older rollouts - keying on it collapsed every file to one record. - # session_id + timestamp, not the filename and not a sequence: a resumed - # session replays its earlier events into a new file, so the same turn is - # written twice with the same stamp. Sequence numbers restart per file and - # would pair unrelated events; `ordinal` is absent from older rollouts. - records, model, session = {}, None, os.path.basename(path) - try: - with open(path, errors="replace") as fh: - for line in fh: - if ('"model"' not in line and '"token_count"' not in line - and '"session_meta"' not in line): - continue - try: - r = json.loads(line) - except ValueError: - continue - payload = r.get("payload") or {} - if r.get("type") == "session_meta": - session = payload.get("session_id") or session - continue - if r.get("type") == "turn_context": - model = payload.get("model") or model - continue - if payload.get("type") != "token_count": - continue - use = (payload.get("info") or {}).get("last_token_usage") or {} - if not use or not model: - continue - when = iso_epoch(r.get("timestamp") or "") - if not when: - continue - cached = use.get("cached_input_tokens") or 0 - got = dict.fromkeys(RATE_KINDS + ("reasoning",), 0) - got["input"] = max(0, (use.get("input_tokens") or 0) - cached) - got["cache_read"] = cached - got["cache_write"] = use.get("cache_write_input_tokens") or 0 - got["output"] = use.get("output_tokens") or 0 - got["reasoning"] = use.get("reasoning_output_tokens") or 0 - if not any(got.values()): - continue - records[(session, r.get("timestamp"))] = ( - datetime.date.fromtimestamp(when).isoformat(), model, got) - except OSError: - return {} - _ROLLOUT_MODELS[path] = (key, records) - return records - - -def codex_daily(): - """Every rollout's per-day, per-model tokens, de-duplicated.""" - seen = {} - for path in glob.glob(CODEX_SESSIONS, recursive=True): - seen.update(scan_rollout_models(path)) - merged = {} - for day, model, tokens in seen.values(): - bucket = merged.setdefault(day, {}).setdefault( - model, dict.fromkeys(RATE_KINDS + ("reasoning",), 0)) - for kind in tokens: - bucket[kind] = bucket.get(kind, 0) + tokens[kind] - return merged - - -def codex_session_count(): - """Distinct sessions, not rollout files. - - Thirty rollouts here hold eight sessions: resuming writes a new file for - a session that already existed. Counting files and calling them sessions - was the same mistake that made the totals wrong, in the label. - """ - ids = set() - for path in glob.glob(CODEX_SESSIONS, recursive=True): - ids.update(key[0] for key in scan_rollout_models(path)) - return len(ids) - - -def codex_totals(daily): - """Totals from the de-duplicated per-turn deltas. - - Not from the rollout tails. `total_token_usage` is cumulative for the - *session*, and a session spans several files - thirty rollouts here hold - only eight sessions - so summing one tail per file counted most sessions - two or three times over: 664.5M against a true 370.0M for the primary - model. Summing the per-turn deltas instead reproduces Codex's own - cumulative figure exactly on four of those eight sessions, and picks up - the review model besides, which the session total never included. - """ - out = {"input_tokens": 0, "output_tokens": 0, "reasoning_output_tokens": 0, - "cached_input_tokens": 0, "total_tokens": 0} - for models in (daily or {}).values(): - for tokens in models.values(): - out["input_tokens"] += tokens.get("input", 0) + tokens.get("cache_read", 0) - out["cached_input_tokens"] += tokens.get("cache_read", 0) - out["output_tokens"] += tokens.get("output", 0) - out["reasoning_output_tokens"] += tokens.get("reasoning", 0) - out["total_tokens"] = out["input_tokens"] + out["output_tokens"] - return out - - -def read_codex(): - """Codex rollouts carry token_count events with running and per-turn use. - - ~/.codex/logs is diagnostics and has no counters, which is where an - earlier look stopped. The sessions directory is the one that counts. - """ - files = sorted(glob.glob(CODEX_SESSIONS, recursive=True), - key=os.path.getmtime) - if not files: - return {"ok": False, "why": "no session rollouts"} - total = {"input_tokens": 0, "output_tokens": 0, - "reasoning_output_tokens": 0, "total_tokens": 0, - "cached_input_tokens": 0} - counted = 0 - daily = {} - limits, limits_at = None, "" - for path in files: - got = scan_rollout(path) - if not got: - continue - if got["total"]: - counted += 1 - for day, n in got["daily"].items(): - daily[day] = daily.get(day, 0) + n - if got["limits"] and (got["limits_at"] or "") > limits_at: - limits, limits_at = got["limits"], got["limits_at"] - # Totals come from the de-duplicated per-turn deltas, not from summing - # each file's cumulative tail - see codex_totals for why that was wrong. - daily_models = codex_daily() - total = codex_totals(daily_models) - - # per-turn rate, from the newest rollout only - rates, prev = [], None - for line in tail_lines(files[-1], 4 * 1024 * 1024): - if '"token_count"' not in line: - continue - try: - d = json.loads(line) - except ValueError: - continue - u = ((d.get("payload") or {}).get("info") or {}).get( - "last_token_usage") or {} - out, ts = u.get("output_tokens") or 0, d.get("timestamp") - if ts and prev and out: - try: - a = datetime.datetime.fromisoformat(prev.replace("Z", "+00:00")) - b = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")) - except ValueError: - prev = ts - continue - gap = (b - a).total_seconds() - if 0.5 < gap < 300: - rates.append(out / gap) - prev = ts or prev - rates.sort() - return {"ok": True, "sessions": codex_session_count() or counted, - "files": len(files), - "total": total, "rates": rates, "daily": daily, - "daily_models": daily_models, - "limits": limits, "limits_at": limits_at, "live": cached("codex", codex_live), - "last": os.path.getmtime(files[-1])} - - -def codex_plan_rows(state, w): - """Plan type and a credit balance - all Codex publishes about the plan. - - Three lines rather than the section Copilot and Cursor get, because - three lines is genuinely all there is. Credits belong here rather than - under the quota bars: they are what the plan grants, not a window. - """ - live = state.get("live") or {} - plan = live.get("plan_type") or (state.get("limits") or {}).get("plan_type") - credits = (live.get("credits") - or (state.get("limits") or {}).get("credits") or {}) - pairs = [] - if credits: - pairs.append(("credits", "unlimited" if credits.get("unlimited") - else str(credits.get("balance") or "0"))) - if (live.get("spend_control") or {}).get("individual_limit"): - pairs.append(("spend limit", - str(live["spend_control"]["individual_limit"]))) - if not plan and not pairs: - return [] - return plan_rows(plan, pairs, w) - - -def codex_metered(state, w): - daily = state.get("daily_models") or {} - return metered_rows([("today", window_models(daily, 1)), - ("30 days", window_models(daily, 30))], w, - agent="codex", scope="this machine", - caveat="CLI rollouts only. Codex bills Cloud, Web," - " Desktop and the rest to the same account," - " and none of those leave anything on this" - " disk to count.") - - -def codex_tab(state, w, h): - if not state.get("ok"): - return no_local("No session rollouts on this machine.", - RUN_HINT["codex"], w) - t = state["total"] - rows = [] - # The one genuine quota figure any of these agents publishes: the server - # sends it back with each response and the rollout records it. It is a - # snapshot from whenever Codex last ran, not a live reading, so it is - # dated. - live = state.get("live") or {} - lanes, source, plan = [], "", live.get("plan_type") or "" - if live.get("rate_limit"): - source = "live" - for key in ("primary_window", "secondary_window"): - win = (live["rate_limit"] or {}).get(key) - if win and win.get("used_percent") is not None: - lanes.append(("", win["used_percent"], - win.get("limit_window_seconds"), - win.get("reset_at"))) - # Some features meter separately from the account's general usage - - # Spark is one - and each arrives named, with its own window and - # reset. Rendering the list rather than the one name we know keeps - # any future feature working without an edit. - for extra in live.get("additional_rate_limits") or []: - win = ((extra.get("rate_limit") or {}).get("primary_window") or {}) - if win.get("used_percent") is None: - continue - lanes.append((extra.get("limit_name") or "?", win["used_percent"], - win.get("limit_window_seconds"), win.get("reset_at"))) - elif (state.get("limits") or {}).get("primary"): - source = "from the last session" - win = state["limits"]["primary"] - lanes.append(("", win.get("used_percent"), - (win.get("window_minutes") or 0) * 60, - win.get("resets_at"))) - if lanes: - rows.append(seg([(LBL, " ── QUOTA ── "), - (OK if source == "live" else WARN, source), - (DIM, scope_phrase(w, 13 + len(source) + len(plan))), - (DIM, plan)], w - 1)) - prepared = [] - for name, pct, window, reset in lanes: - secs = int(window or 0) - wname = ("%dd" % (secs // 86400) if secs >= 86400 - else "%dh" % (secs // 3600) if secs else "?") - when = "" - if reset: - left = reset - time.time() - when = ("resets in %dd %dh" % (left // 86400, - (left % 86400) // 3600) - if left > 0 else "resetting") - prepared.append((name, wname, pct, when, secs, reset)) - - # Alone, the account-wide lanes are told apart by their window and a - # bare "7d" is clear enough. Beside a named one it is not, so it says - # what it covers only when there is something to confuse it with. - named = any(name for name, _, _, _, _, _ in prepared) - - def labels(short): - """Spell a feature out while there is room; below that the last - segment carries it - GPT-5.3-Codex-Spark is Spark.""" - out = [] - for name, wname, _, _, _, _ in prepared: - n = name.rsplit("-", 1)[-1] if (short and name) else name - out.append(("%s %s" % (n or ("overall" if named else ""), - wname)).strip()) - return out - - lab = labels(False) - if w - 32 - max(len(x) for x in lab) < 20: - lab = labels(True) - label_w = max(9, max(len(x) for x in lab)) - for (_, _, pct, when, secs, reset), text in zip(prepared, lab): - used = (pct or 0) / 100.0 - # heat(used), not heat(1 - used): red belongs at a quota nearly - # spent, and the inverse painted a 26%-used week amber - rows.append(seg([(DIM, " " + pad(text, label_w) + " ")] - + paced_bar(used, elapsed_of(secs, reset), - max(8, w - 34 - label_w), - AGENT_HUE["codex"]) - + [(pct_colour(pct, ahead_of(lead(pct, secs, reset)), AGENT_HUE["codex"]), - pct_text(pct)), - pace_cell(lead(pct, secs, reset)), - (DIM, " " + when)], w - 1)) - rows.append("") - # Everything Codex publishes about the subscription itself: a plan - # word and a credit balance, which is why this is three lines and - # not the section Copilot and Cursor get. Credits live here rather - rows.append(seg([(LBL, " ── TOTALS ── "), - (DIM, "%d sessions · newest %s ago" - % (state["sessions"], ago(state["last"])))], w - 1)) - cells = [("input tokens", big_num(t["input_tokens"]), TXT), - ("output tokens", big_num(t["output_tokens"]), AGENT), - ("reasoning tokens", big_num(t["reasoning_output_tokens"]), TXT), - ("cached input", big_num(t["cached_input_tokens"]), DIM), - ("all tokens", big_num(t["total_tokens"]), TXT), - ("rollout files", "%d" % state["files"], DIM)] - label_w = max(len(c[0]) for c in cells) - ncols = 2 if (w - 2) // 2 - label_w - 3 >= 8 else 1 - cw = (w - 2) // ncols - val_w = max(5, cw - label_w - 3) - for i in range(0, len(cells), ncols): - line = [(RST, " ")] - for label, value, colour in cells[i:i + ncols]: - line += [(DIM, " " + pad(label, label_w) + " "), - (colour, pad(value, val_w))] - rows.append(seg(line, w - 1)) - - rates = state["rates"] - rows.append("") - if rates: - med = rates[len(rates) // 2] - p90 = rates[min(len(rates) - 1, int(len(rates) * 0.9))] - rows.append(seg([(LBL, " ── OUTPUT RATE ── "), - (DIM, "newest session, %d turns" % len(rates))], w - 1)) - rows.append(seg([(DIM, " median "), (AGENT, "%.0f" % med), - (DIM, " tok/s p90 "), (TXT, "%.0f" % p90), - (DIM, " max "), (TXT, "%.0f" % rates[-1])], w - 1)) - hi = rates[-1] or 1 - # The bucket count stays tied to the sample count - twenty-eight - # turns spread over fifty columns is a comb, not a distribution - - # but each bucket is then drawn as wide as the pane allows, so the - # chart fills its line instead of stopping a third of the way in. - buckets = [0] * max(10, min(len(rates), w - 6)) - for r in rates: - buckets[min(len(buckets) - 1, - int(r / hi * (len(buckets) - 1)))] += 1 - cols = [] - for b, wide in zip(buckets, spread(len(buckets), max(10, w - 3))): - cols.extend([(b, AGENT)] * wide) - for line in vbars(cols, 3): - rows.append(seg([(RST, " ")] + line, w - 1)) - rows.append(seg([(RST, " "), (GRID, "─" * len(cols))], w - 1)) - right = "%.0f tok/s" % rates[-1] - rows.append(seg([(DIM, " 0 tok/s"), - (DIM, " " * max(1, len(cols) - 8 - len(right))), - (DIM, right)], w - 1)) - grid, peak, best, facts = day_calendar(state.get("daily") or {}, w, - CODEX_STEPS) - if grid: - rows.append("") - rows.append(seg([(LBL, " ── TOKENS / DAY ── "), - (DIM, "peak "), (AGENT, big_num(peak)), - (DIM, " on %s" % (best.strftime("%b %-d") if best - else "--"))], w - 1)) - for line in grid: - rows.append(seg(line, w - 1)) - rows.append(seg([(DIM, " Less ")] - + [(rgb(*c), "█") for c in CODEX_STEPS] - + [(DIM, " More")], w - 1)) - rows.append("") - rows.append(seg([(DIM, " Tokens and rate are measured here, from the" - " rollouts. Quota is")], w - 1)) - rows.append(seg([(DIM, " the account's, fetched from the same endpoint" - " the Codex CLI uses.")], w - 1)) - return rows - - -_GROK_CACHE = {} - - -def grok_quota(): - """Grok's weekly credit window, as its own CLI receives it. - - Not hidden and not inferred: the server sends it, and the CLI writes it - into the session log under `.ctx.config`. An earlier pass here concluded - no quota existed, having grepped for limit/quota/remaining/reset - the - keys are `creditUsagePercent` and `currentPeriod`, so the search missed - them and the tab said so in print for a day. - """ - best = None - for path in [GROK_LOG] if os.path.exists(GROK_LOG) else []: - for line in tail_lines(path, 2 * 1024 * 1024): - if "creditUsagePercent" not in line: - continue - try: - d = json.loads(line) - except ValueError: - continue - ctx = d.get("ctx") if isinstance(d, dict) else None - cfg = (ctx or {}).get("config") if isinstance(ctx, dict) else None - if not isinstance(cfg, dict) or "creditUsagePercent" not in cfg: - continue - period = cfg.get("currentPeriod") or {} - when = period.get("start") or "" - if best is None or when >= best["start"]: - best = {"percent": cfg.get("creditUsagePercent"), - "kind": period.get("type") or "", - "start": when, "end": period.get("end") or "", - "tier": (ctx or {}).get("subscriptionTier") or "", - "on_demand_used": (cfg.get("onDemandUsed") or {}).get("val"), - "on_demand_cap": (cfg.get("onDemandCap") or {}).get("val"), - "prepaid": (cfg.get("prepaidBalance") or {}).get("val")} - if best: - break - return best - - -def read_grok(): - """Grok logs a running totalTokens on each session event. - - Deltas between consecutive events, bucketed by the event's own timestamp, - give per-day figures; the running total alone would credit an entire - session to whichever day it happened to be read on. Cached per file on - mtime and size, like the Codex rollouts. - """ - files = glob.glob(GROK_SESSIONS, recursive=True) - if not files: - return {"ok": False, "why": "no sessions on disk"} - total, daily, sessions, newest = 0, {}, 0, 0 - for path in files: - try: - st = os.stat(path) - except OSError: - continue - key = (st.st_mtime, st.st_size) - hit = _GROK_CACHE.get(path) - if hit and hit[0] == key: - got = hit[1] - else: - got, prev = {"total": 0, "daily": {}}, 0 - try: - with open(path, errors="ignore") as f: - for line in f: - m = re.search(r'"totalTokens":(\d+)', line) - if not m: - continue - value = int(m.group(1)) - step = value - prev - prev = max(prev, value) - if step <= 0: - continue - when = re.search(r'"agentTimestampMs":(\d+)', line) - if not when: - continue - day = datetime.datetime.fromtimestamp( - int(when.group(1)) / 1000.0, - datetime.timezone.utc).date().isoformat() - got["daily"][day] = got["daily"].get(day, 0) + step - got["total"] += step - except OSError: - continue - _GROK_CACHE[path] = (key, got) - if got["total"]: - sessions += 1 - total += got["total"] - for day, n in got["daily"].items(): - daily[day] = daily.get(day, 0) + n - newest = max(newest, st.st_mtime) - return {"ok": True, "sessions": sessions, "files": len(files), - "total": total, "daily": daily, "last": newest, - "quota": grok_quota()} - - -def grok_plan_rows(q, w): - """Grok states its tier and the kind of period it bills in, and no more. - - Both arrive on the client log beside the credit percentage, so this - costs nothing extra to show. - """ - if not q: - return [] - pairs = [] - kind = (q.get("kind") or "").replace("USAGE_PERIOD_TYPE_", "").lower() - if kind: - pairs.append(("billing period", kind)) - cap = q.get("on_demand_cap") - if cap is not None: - pairs.append(("on-demand", "%s of %s used" - % (q.get("on_demand_used") or 0, cap))) - if q.get("prepaid") is not None: - pairs.append(("prepaid balance", str(q["prepaid"]))) - return plan_rows(q.get("tier"), pairs, w) - - -def grok_tab(state, w, h): - if not state.get("ok"): - return no_local("No Grok sessions on this machine.", - RUN_HINT["grok"], w) - rows = [] - q = state.get("quota") - if q and q.get("percent") is not None: - # The one real remaining-quota figure in this widget: everything else - # here counts what was spent. It leads the tab for that reason. - used = float(q["percent"]) / 100.0 - left = None - try: - end = datetime.datetime.fromisoformat(q["end"]) - left = (end - datetime.datetime.now( - datetime.timezone.utc)).total_seconds() / 86400.0 - except (ValueError, TypeError): - pass - period = ("weekly" if "WEEKLY" in (q.get("kind") or "") - else (q.get("kind") or "").replace( - "USAGE_PERIOD_TYPE_", "").lower() or "current") - rows.append(seg([(LBL, " ── %s QUOTA ── " % period.upper()), - (DIM, "resets in %.1f days" % left - if left is not None and left >= 0 else "")], w - 1)) - span, reset_ts = None, None - begin, finish = iso_epoch(q.get("start") or ""), iso_epoch(q.get("end") or "") - if begin and finish and finish > begin: - span, reset_ts = finish - begin, finish - rows.append(seg([(pct_colour(float(q["percent"]), - ahead_of(lead(float(q["percent"]), span, - reset_ts)), - AGENT_HUE["grok"]), - " %-5s" % ("%.0f%%" % q["percent"]))] - + paced_bar(used, elapsed_of(span, reset_ts), - max(10, w - 38), AGENT_HUE["grok"]) - + [(DIM, " credits used"), - pace_cell(lead(float(q["percent"]), span, - reset_ts))], w - 1)) - span = "" - for key, label in (("start", ""), ("end", " → ")): - try: - span += label + datetime.datetime.fromisoformat( - q[key]).strftime("%-d %b") - except (ValueError, TypeError, KeyError): - span = "" - break - extras = [] - if q.get("on_demand_cap"): - extras.append("on-demand %s/%s" % (q.get("on_demand_used"), - q["on_demand_cap"])) - if q.get("prepaid"): - extras.append("prepaid %s" % q["prepaid"]) - rows.append(seg([(DIM, " window "), (TXT, span or "?"), - (DIM, " " + " · ".join(extras) if extras else "")], - w - 1)) - rows.append("") - rows.append(seg([(LBL, " ── TOTALS ── "), - (DIM, "%d sessions · newest %s ago" - % (state["sessions"], ago(state["last"])))], w - 1)) - rows.append(seg([(DIM, " tokens "), (AGENT, big_num(state["total"])), - (DIM, " across %d session files" % state["files"])], - w - 1)) - grid, peak, best, facts = day_calendar(state.get("daily") or {}, w, - GROK_STEPS) - if grid: - rows.append("") - rows.append(seg([(LBL, " ── TOKENS / DAY ── "), - (DIM, "peak "), (AGENT, big_num(peak)), - (DIM, " on %s" % (best.strftime("%b %-d") if best - else "--"))], w - 1)) - for line in grid: - rows.append(seg(line, w - 1)) - rows.append(seg([(DIM, " Less ")] - + [(rgb(*c), "█") for c in GROK_STEPS] - + [(DIM, " More")], w - 1)) - rows.append("") - rows.append(seg([(DIM, " Totals are a running count per session, summed" - " as deltas so a")], w - 1)) - rows.append(seg([(DIM, " session spanning days lands on the right one." - " The quota above is")], w - 1)) - rows.append(seg([(DIM, " the server's own figure, read from the client" - " log - not inferred.")], w - 1)) - return rows - - -# Agents whose usage exists but is not readable from outside their session. -# Naming where the number actually lives beats showing an empty gauge. -ELSEWHERE = {} - - -ANTIGRAVITY_RPC = ("/exa.language_server_pb.LanguageServerService" - "/RetrieveUserQuotaSummary") - - -def antigravity_ports(): - """TCP ports the running Antigravity language server listens on. - - The quota never reaches disk, but the process holding it serves an RPC - on localhost, so the port is the way in. Found by matching the process - then reading its listening sockets out of /proc - no lsof, no guessing - at a range, and nothing touched but our own machine. - """ - pids = [] - for entry in os.listdir("/proc"): - if not entry.isdigit(): - continue - try: - with open("/proc/%s/cmdline" % entry, "rb") as f: - cmd = f.read().replace(b"\x00", b" ").decode("utf8", "replace") - except OSError: - continue - if re.search(r"(^|/)(agy|antigravity)\b|language_server", cmd): - pids.append(entry) - inodes = set() - for pid in pids: - for fd in glob.glob("/proc/%s/fd/*" % pid): - try: - target = os.readlink(fd) - except OSError: - continue - if target.startswith("socket:["): - inodes.add(target[8:-1]) - ports = [] - for table in ("/proc/net/tcp", "/proc/net/tcp6"): - try: - with open(table) as f: - rows = f.read().splitlines()[1:] - except OSError: - continue - for row in rows: - cols = row.split() - if len(cols) > 9 and cols[3] == "0A" and cols[9] in inodes: - ports.append(int(cols[1].split(":")[1], 16)) - return sorted(set(ports)) - - -def antigravity_quota(): - """Weekly and five-hour limits, from the language server on localhost. - - The same figures Antigravity's own TUI prints. It speaks Connect over - plain HTTP on a loopback port - its TLS listener answers with a wrong - version number, so http is not a downgrade here, it is the protocol - - and the call leaves this machine no more than reading a file would. - - Found by reading how CodexBar does it (github.com/steipete/CodexBar), - after chasing the Google endpoint in the binary to a 404: the quota was - never a remote call to make, it was a local one. - """ - for port in antigravity_ports(): - req = urllib.request.Request( - "http://127.0.0.1:%d%s" % (port, ANTIGRAVITY_RPC), data=b"{}", - method="POST", headers={"Content-Type": "application/json"}) - try: - with urllib.request.urlopen(req, timeout=5) as r: - got = json.load(r) - except (urllib.error.URLError, ValueError, OSError): - continue - groups = ((got.get("response") or got).get("groups") or []) - if groups: - return groups - return None - - -def antigravity_live(): - """Which Code Assist tier the account is on. - - Antigravity keeps no quota and no token counts on disk - its language - server refreshes a quota into memory and is not even installed between - runs - so this endpoint is the only thing that can answer anything, and - what it answers is the subscription rather than the spend. - - The access token expires hourly and Antigravity refreshes it; an expired - one is skipped rather than refreshed here, for the same reason Claude's - is: that is the CLI's job and racing it would be rude. - """ - try: - with open(ANTIGRAVITY_TOKEN) as f: - tok = (json.load(f) or {}).get("token") or {} - except (OSError, ValueError): - return None - access = tok.get("access_token") - exp = iso_epoch(tok.get("expiry") or "") - if not access or (exp and exp <= time.time()): - return None - req = urllib.request.Request( - CODE_ASSIST_API, method="POST", - data=json.dumps({"metadata": {"pluginType": "GEMINI"}}).encode(), - headers={"Authorization": "Bearer " + access, - "Content-Type": "application/json", - # Google gates this response on the client string. Sent as - # plain terminal-toys it answers UNSUPPORTED_CLIENT and - # returns no tier at all; the parenthesised form is the - # conventional way to name the client being spoken for - # while still saying who is actually calling. - "User-Agent": "terminal-toys (antigravity-cli)"}) - try: - with urllib.request.urlopen(req, timeout=20) as r: - return json.load(r) - except (urllib.error.URLError, ValueError, OSError): - return None - - -def read_antigravity(): - """What the conversation stores record, which is activity and not cost. - - Each conversation is its own SQLite file with a `steps` table - one row - per step the agent took - so the counts are real work done. No table - anywhere carries a token count. - """ - out = {"ok": True, "live": cached("antigravity", antigravity_live, - ttl=PLAN_TTL), - "quota": cached("antigravity-quota", antigravity_quota), - "sessions": 0, "steps": 0, "prompts": 0, "last": 0} - files = glob.glob(ANTIGRAVITY_CONVERSATIONS) - out["sessions"] = len(files) - for path in files: - try: - out["last"] = max(out["last"], os.path.getmtime(path)) - con = sqlite3.connect("file:%s?mode=ro" % path, uri=True) - out["steps"] += con.execute( - "select count(*) from steps").fetchone()[0] - con.close() - except (sqlite3.Error, OSError): - continue - try: - with open(ANTIGRAVITY_HISTORY) as f: - out["prompts"] = sum(1 for line in f if line.strip()) - out["last"] = max(out["last"], os.path.getmtime(ANTIGRAVITY_HISTORY)) - except OSError: - pass - if not files and not out["prompts"]: - out["why"] = "no conversations recorded" - return out - - -def antigravity_plan_rows(state, w): - live = state.get("live") or {} - cur = live.get("currentTier") or {} - paid = live.get("paidTier") or {} - if not cur and not paid: - return [] - pairs = [] - if cur.get("id"): - pairs.append(("code assist tier", cur["id"])) - # paidTier is the Google AI subscription behind the account, which is a - # different thing from the Code Assist tier and can disagree with it - - # free-tier here, while the account is on Ultra. Both are stated. - if paid.get("name"): - pairs.append(("google ai plan", paid["name"])) - if live.get("cloudaicompanionProject"): - pairs.append(("project", live["cloudaicompanionProject"])) - try: - with open(ANTIGRAVITY_TOKEN) as f: - method = (json.load(f) or {}).get("auth_method") or "" - if method: - pairs.append(("auth", method)) - except (OSError, ValueError): - pass - # The two tiers can disagree - free-tier beside Google AI Ultra is - # normal, since one is GCP licensing and the other a consumer plan - but - # that is a paragraph the docs can carry, not four lines on every frame. - return plan_rows(cur.get("name") or paid.get("name"), pairs, w) - - -ANTIGRAVITY_WINDOWS = {"weekly": 7 * 86400, "5h": 5 * 3600} - - -def antigravity_quota_rows(groups, w): - """One bar per limit, grouped by the model family it covers. - - Shown as spent rather than the remaining fraction the RPC returns, so - red means the same here as on every other tab. Every plan reports every - family it covers, so a Gemini-only account still gets a Claude/GPT pair - sitting at 0% - they are real limits, not padding, and are left in. - """ - if not groups: - return [] - # The long form names where the number comes from, which matters here - # more than elsewhere; the short one still says it is not this machine's - # own tally. Shortened before it can clip, as the other headers are. - note = " · account-wide, from the local language server" - for shorter in (" · from the local server", " · local"): - if 13 + len("live") + len(note) <= w - 1: - break - note = shorter - rows = [seg([(LBL, " ── QUOTA ── "), (OK, "live"), (DIM, note)], w - 1)] - for group in groups: - buckets = [b for b in (group.get("buckets") or []) - if b.get("remainingFraction") is not None] - if not buckets: - continue - rows.append(seg([(TXT, " " + str(group.get("displayName") or "?"))], - w - 1)) - label_w = max(len(str(b.get("window") or "?")) for b in buckets) - for b in buckets: - pct = 100.0 * (1.0 - float(b["remainingFraction"])) - used = max(0.0, min(1.0, pct / 100.0)) - window = str(b.get("window") or "?") - reset = iso_epoch(b.get("resetTime") or "") - when = "" - if reset: - left = reset - time.time() - when = ("resets in " + left_span(left) if left > 0 - else "resetting") - room = w - 36 - label_w - if room < 8: - # Below the bar's floor the row cannot shrink further, so the - # reset stands down rather than being cut in half. - when = "" - room = w - 20 - label_w - rows.append(seg([(DIM, " " + pad(window, label_w) + " ")] - + paced_bar(used, elapsed_of( - ANTIGRAVITY_WINDOWS.get(window), reset), - max(8, room), AGENT_HUE["antigravity"]) - + [(pct_colour(pct, ahead_of(lead( - pct, ANTIGRAVITY_WINDOWS.get(window), reset)), - AGENT_HUE["antigravity"]), - pct_text(pct)), - pace_cell(lead(pct, - ANTIGRAVITY_WINDOWS.get(window), - reset)), - (DIM, " " + when)], w - 1)) - rows.append("") - return rows - - -def antigravity_tab(state, w, h): - rows = antigravity_quota_rows(state.get("quota"), w) - if state.get("live") is None: - rows.append(seg([(WARN, " no tier: the CLI's access token has" - " expired or the call failed")], w - 1)) - rows.append("") - rows.append(seg([(LBL, " ── ACTIVITY ── "), - (DIM, "local · %s" % ("last %s ago" % ago(state["last"]) - if state.get("last") - else "never run here"))], w - 1)) - cells = [("conversations", "%d" % state.get("sessions", 0), TXT), - ("agent steps", "%d" % state.get("steps", 0), AGENT), - ("prompts", "%d" % state.get("prompts", 0), TXT)] - label_w = max(len(c[0]) for c in cells) - for label, value, colour in cells: - rows.append(seg([(DIM, " " + pad(label, label_w) + " "), - (colour, value)], w - 1)) - rows.append("") - # The absence is only worth explaining while it is one. With the quota - # drawn above, a paragraph about why there is no quota contradicts the - # screen. - if state.get("quota"): - rows += no_local("No per-token usage is recorded locally - the" - " conversations and steps above are what there is.", - "", w) - else: - rows += no_local("No tokens are recorded locally, and no quota" - " either: it comes from the language server while" - " Antigravity is running, so start it and this fills" - " in.", "", w) - return rows - - -def copilot_token(): - """The CLI keeps its OAuth token in ~/.copilot/config.json. - - That file is JSON with `//` comments on top, which json.load refuses, so - the comments come off first. Keyed by host and login, because one machine - can be signed in to github.com and an Enterprise host at once. - """ - try: - with open(COPILOT_CONFIG) as f: - raw = re.sub(r"^\s*//.*$", "", f.read(), flags=re.M) - toks = (json.loads(raw) or {}).get("copilotTokens") or {} - except (OSError, ValueError): - return None - for host, tok in toks.items(): - if tok: - return tok - return None - - -def copilot_live(): - """Entitlement and quota, from the endpoint the Copilot CLI itself uses. - - This is where Copilot's remaining quota actually lives. The session store - records what was spent per turn and is empty on plenty of machines; this - is the account's standing, and it answers the only question a limit pane - is really asked. - """ - tok = copilot_token() - if not tok: - return {"why": "no token in ~/.copilot/config.json"} - req = urllib.request.Request(COPILOT_USER_API, headers={ - "Authorization": "token " + tok, "User-Agent": "terminal-toys", - "Accept": "application/json"}) - try: - with urllib.request.urlopen(req, timeout=20) as r: - return {"data": json.load(r)} - except (urllib.error.URLError, ValueError, OSError) as e: - # Which of the two went wrong matters: blaming the config file for a - # dropped connection sends the reader to edit a file that is fine. - return {"why": "quota request failed: %s" % str(e)[:40]} - - -def read_copilot(): - """Live quota, plus whatever the local session store has recorded. - - The two halves are independent: the quota is the account's and arrives - over the network, while the per-turn detail is this machine's and is - frequently empty. Either can be present without the other. - """ - got = cached("copilot", copilot_live) or {} - out = {"ok": True, "live": got.get("data"), "live_why": got.get("why"), - "sessions": 0, "events": 0, "usage": None} - if not os.path.exists(COPILOT_DB): - out["why"] = "no session store" - return out - try: - con = sqlite3.connect("file:%s?mode=ro" % COPILOT_DB, uri=True) - out["sessions"] = con.execute( - "select count(*) from sessions").fetchone()[0] - row = con.execute( - "select count(*), sum(input_tokens), sum(output_tokens)," - " sum(cache_read_tokens), sum(reasoning_tokens)," - " sum(total_nano_aiu), avg(time_to_first_token_ms)," - " avg(inter_token_latency_ms), sum(duration_ms)" - " from assistant_usage_events").fetchone() - out["events"] = row[0] or 0 - if out["events"]: - out["usage"] = { - "input": row[1] or 0, "output": row[2] or 0, - "cache": row[3] or 0, "reasoning": row[4] or 0, - "nano_aiu": row[5] or 0, "ttft": row[6] or 0, - "itl": row[7] or 0, "ms": row[8] or 0, - } - for day, model, i_tok, o_tok, cr, cw in con.execute( - "select date(created_at), model, sum(input_tokens)," - " sum(output_tokens), sum(cache_read_tokens)," - " sum(cache_write_tokens) from assistant_usage_events" - " group by 1, 2"): - bucket = out.setdefault("daily_models", {}).setdefault( - str(day), {}).setdefault( - model, dict.fromkeys(RATE_KINDS, 0)) - bucket["input"] += i_tok or 0 - bucket["output"] += o_tok or 0 - bucket["cache_read"] += cr or 0 - bucket["cache_write"] += cw or 0 - out["models"] = con.execute( - "select model, count(*), sum(output_tokens)," - " sum(input_tokens), sum(cache_read_tokens)," - " sum(cache_write_tokens)" - " from assistant_usage_events group by model" - " order by 3 desc limit 6").fetchall() - con.close() - except sqlite3.Error as e: - out["why"] = str(e)[:40] - return out - - -# The account's own description of itself. Names are what the API returns, -# shortened only where it repeats itself. -COPILOT_FEATURES = (("chat_enabled", "chat"), - ("cli_enabled", "cli"), - ("is_mcp_enabled", "mcp"), - ("cli_remote_control_enabled", "remote control"), - ("cloud_session_storage_enabled", "cloud sessions"), - ("copilot_app_enabled", "app"), - ("editor_preview_features_enabled", "editor previews"), - ("copilotignore_enabled", "copilotignore")) - - -def wrap_text(text, budget): - """Plain text flowed to a width. Clipping a sentence loses its end.""" - lines, line = [], "" - for word in str(text).split(): - if line and len(line) + 1 + len(word) > budget: - lines.append(line) - line = word - else: - line = (line + " " + word) if line else word - if line: - lines.append(line) - return lines or [""] - - -def wrap_pair(key, value, label_w, w): - """A labelled value flowed onto as many lines as it needs. - - Only text can be wrapped. A bar chart broken across two lines is not a - bar chart, and a table row wrapped mid-row loses the columns that made - it a table - which is why those still adapt to the width instead. A - value like an enterprise sku is just words, so it wraps, and the - continuation lines sit under the value rather than under the label. - """ - budget = max(8, w - label_w - 5) - words, lines, line = str(value).split(), [], "" - for word in words: - if line and len(line) + 1 + len(word) > budget: - lines.append(line) - line = word - else: - # A single word longer than the column is split rather than - # allowed to run off; a sku is one word and still has to fit. - while len(word) > budget: - lines.append(word[:budget]) - word = word[budget:] - line = (line + " " + word).strip() if line else word - if line: - lines.append(line) - return [(key if not i else "", part) for i, part in enumerate(lines)] - - -# What to run to make each agent start recording. An empty tab that only -# says "nothing here" leaves the reader to guess whether it is broken. -RUN_HINT = {"claude": "claude", "codex": "codex", "cursor": "cursor-agent", - "grok": "grok", "copilot": "copilot"} - - -def no_local(what, run, w): - """The empty state: what is missing, and the one command that fixes it. - - These tabs used to explain the schema they would have used - the tables, - the column names, why it would have been the best data of the lot. That - is interesting exactly once, and after that it is a wall of text sitting - where the numbers should be. Two lines say as much and answer the only - question an empty tab actually raises. - """ - rows = [seg([(DIM, " " + line)], w - 1) - for line in wrap_text(what, max(20, w - 4))] - if run: - rows.append(seg([(DIM, " run "), (ACCENT, run), - (DIM, " here and this fills in")], w - 1)) - return rows - - -def rate_for(model): - """The rate for a model, and where it came from. - - Config wins outright, then the published list prices. Keyed by model - rather than by agent, because a model has one list price wherever it - ran - the same claude-sonnet-5 entry prices Copilot's turns and Claude - Code's. Longest matching name wins, so claude-opus-4 does not shadow - claude-opus-4-8, and a "*" entry catches anything left over. - """ - name = str(model or "") - if name in NO_PUBLISHED_PRICE and name not in RATES: - return None, None - for table, origin in ((RATES, "config"), (LIST_RATES, "list")): - if name in table: - return table[name], origin - best = None - for key, rate in table.items(): - if key != "*" and key in name: - if best is None or len(key) > len(best[0]): - best = (key, rate) - if best: - return best[1], origin - if table.get("*"): - return table["*"], origin - return None, None - - -def cost_of(tokens, rate): - return sum((tokens.get(kind) or 0) / 1e6 * float(rate.get(kind) or 0) - for kind in RATE_KINDS) - - -def metered_block(where, windows, w, extras=None, note="", scope="", - caveat=""): - """The metered section: one row per window, each with its models under it. - - `windows` is [(label, cost, tokens, [(model, cost)])]. Two of them - - today and thirty days - because a month's total says what an agent costs - and today says whether that is still true. A single all-time figure - answered neither question. - """ - # Windows are kept even when empty, as long as something is. A zero - # today against a busy month is the answer to "have I used this today", - # and dropping the row leaves the reader to wonder which it was. - if not any(x[1] or x[2] for x in windows): - return [] - # Scope first, because it is the thing most easily got wrong: this - # section sits under a QUOTA labelled "account-wide", and a local figure - # beside it reads as the same scope unless it says otherwise. Codex's - # own dashboard reports every surface - Desktop, Cloud, Web, the rest - - # and none of those leave anything on this disk. - rows = [seg([(LBL, " ── METERED ── "), - (TXT, scope + " · " if scope else ""), - (DIM, "at " + where), - (DIM, " %s" % note if note else "")], w - 1)] - for line in (wrap_text(caveat, max(20, w - 4)) if caveat else []): - rows.append(seg([(DIM, " " + line)], w - 1)) - extras = [x for x in (extras or []) if x[1] is not None] - label_w = max([len(x[0]) for x in windows] + [len(x[0]) for x in extras]) - for label, cost, tokens, models in windows: - rows.append(seg([(TXT, " " + pad(label, label_w) + " "), - (AGENT, pad("$%.2f" % cost, 11)), - (DIM, big_num(tokens) + " tokens")], w - 1)) - top = models[:5] - name_w = max([len(m) for m, _ in top] or [0]) - for model, model_cost in top: - rows.append(seg([(DIM, " " + " " * label_w + " "), - (DIM, pad(model, name_w) + " "), - (TXT, "$%.2f" % model_cost)], w - 1)) - if len(models) > len(top): - rows.append(seg([(DIM, " " + " " * label_w + " "), - (DIM, "+%d more" % (len(models) - len(top)))], - w - 1)) - # Summary rows below the windows rather than in the header, which had - # grown long enough to clip the moment a scope word joined it. - for label, value, colour in extras: - rows.append(seg([(DIM, " " + pad(label, label_w) + " "), - (colour, "$%.2f" % value)], w - 1)) - rows.append("") - return rows - - -def metered_rows(windows, w, note="", agent=None, scope="", caveat=""): - """Cost a set of windows against the rate card. - - Each window is (label, [(model, tokens)]). Only models with a rate are - counted and the unpriced ones are named, so a half-filled card cannot - read as a total. - """ - origins, missing, built = set(), set(), [] - for label, entries in windows: - cost = tokens = 0.0 - models = [] - for model, counts in entries: - if not any(counts.values()): - continue - rate, origin = rate_for(model) - tokens += sum(counts.get(k) or 0 for k in RATE_KINDS) - if not rate: - missing.add(model) - continue - this = cost_of(counts, rate) - cost += this - origins.add(origin) - models.append((model, this)) - built.append((label, cost, tokens, sorted(models, key=lambda x: -x[1]))) - if not any(x[1] for x in built): - if not RATES: - return [seg([(LBL, " ── METERED ── "), - (DIM, "no published rates for these models")], - w - 1)] + no_local( - "Set usage.rates in config.json - US$ per million tokens," - " keyed by model.", "", w) + [""] - return [] - # Where the prices came from belongs on screen: a list price is a dated - # fact that goes stale in silence, and a configured one is the reader's - # own assertion. Neither should be mistaken for the other. - where = ("your configured rates" if origins == {"config"} - else "list prices · %s" % LIST_RATES_AS_OF if origins == {"list"} - else "list prices · %s, some configured" % LIST_RATES_AS_OF) - # A month's list cost against what the month actually cost you. Shown - # only when the plan price is configured, because it is the one figure - # in this section that no machine here knows. - saves = None - paid = PLAN_COST.get(agent) if agent else None - month = next((x[1] for x in built if x[0] == "30 days"), None) - if paid and month: - saves = month - float(paid) - rows = metered_block(where, built, w, note=note, scope=scope, - caveat=caveat, - extras=[("the plan saves", saves, OK)]) - if missing and rows: - rows.insert(len(rows) - 1, - seg([(WARN, " %d model%s unpriced: " - % (len(missing), "" if len(missing) == 1 else "s")), - (DIM, ", ".join(sorted(missing)[:3]))], w - 1)) - return rows - - -def plan_rows(headline, pairs, w, note="", wrapped=None, caveat=""): - """A subscription block: what the plan is, then the facts about it. - - Shared by four tabs so the same question is answered in the same shape - wherever you are on the wall - a percentage means little without the - subscription it is a percentage of. - """ - rows = [seg([(LBL, " ── SUBSCRIPTION ── "), (TXT, headline or "unknown"), - (DIM, " " + note if note else "")], w - 1)] - for line in (wrap_text(caveat, max(20, w - 4)) if caveat else []): - rows.append(seg([(DIM, " " + line)], w - 1)) - labels = [k for k, _ in pairs] + ([wrapped[0]] if wrapped else []) - label_w = max([len(x) for x in labels] or [0]) - for key, value in pairs: - for lab, part in wrap_pair(key, value, label_w, w): - rows.append(seg([(DIM, " " + pad(lab, label_w) + " "), - (TXT, part)], w - 1)) - if wrapped and wrapped[1]: - # Wrapped rather than clipped: a truncated list reads as a shorter - # one, and only the first line takes the label. - budget = max(10, w - label_w - 6) - lines, line = [], [] - for name in wrapped[1]: - if line and len(" · ".join(line + [name])) > budget: - lines.append(line) - line = [] - line.append(name) - if line: - lines.append(line) - for i, part in enumerate(lines): - rows.append(seg([(DIM, " " + pad(wrapped[0] if not i else "", - label_w) + " "), - (OK, " · ".join(part))], w - 1)) - return rows - - -def add_section(rows, block): - """Append a section with exactly one blank line before it. - - The separator is owned here rather than by callers who each end - differently - some finish on a blank line and would otherwise leave two, - and plain concatenation leaves none at all, which is what METERED did - when it was bolted on after the tab body. - """ - if not block: - return rows - while rows and rows[-1] == "": - rows.pop() - return rows + [""] + block - - -def copilot_plan_rows(live, w): - """Which subscription this quota belongs to, and since when. - - An enterprise seat is why two of the three pools come back unlimited, - and assigned_date is the only field saying how long it has been so. - """ - pairs = [] - since = iso_epoch(live.get("assigned_date") or "") - day = iso_day(live.get("assigned_date") or "") - if since and day: - pairs.append(("seat since", "%s · %s ago" % (day, ago(since)))) - orgs = [o.get("name") or o.get("login") - for o in (live.get("organization_list") or []) if o] - if orgs: - pairs.append(("organisation", ", ".join(orgs))) - if live.get("login"): - pairs.append(("account", live["login"])) - if live.get("access_type_sku"): - pairs.append(("sku", live["access_type_sku"])) - if live.get("token_based_billing"): - pairs.append(("billing", "token-based")) - on = [name for flag, name in COPILOT_FEATURES if live.get(flag)] - return plan_rows(live.get("copilot_plan"), pairs, w, - note="upgradeable" if live.get("can_upgrade_plan") else "", - wrapped=("enabled", on)) - - -def copilot_metered(state, w): - daily = state.get("daily_models") or {} - return metered_rows([("today", window_models(daily, 1)), - ("30 days", window_models(daily, 30))], w, - agent="copilot", scope="this machine", - caveat="Counted from the local session store. Copilot" - " in an editor, on another machine or on" - " github.com is not in here.") - - -def copilot_tab(state, w, h): - live = state.get("live") or {} - rows = [] - snaps = live.get("quota_snapshots") or {} - if snaps: - # A monthly quota reset arrives as a bare date; days remaining is the - # form every other tab here uses, and the one anyone reads. - # quota_reset_date_utc, not quota_reset_date: the bare date carries - # no zone, so it parses as local midnight and the countdown drifts by - # the machine's UTC offset. Zero on this server, which is exactly why - # it would have gone unnoticed here. - stamp = iso_epoch(live.get("quota_reset_date_utc") or "") - when = "" - if stamp: - days = int((stamp - time.time()) // 86400) - when = "resets in %dd" % days if days > 0 else "resets today" - rows.append(seg([(LBL, " ── QUOTA ── "), (OK, "live"), - (DIM, " · account-wide")], w - 1)) - # The reset sits with the window rather than on the header: they are - # two halves of the same cycle, and the header had no room for it. - span = quota_window(stamp) - if span or when: - rows.append(seg([(DIM, " window "), (TXT, span or "—"), - (DIM, " · monthly · " if span else " "), - (DIM, when)], w - 1)) - label_w = max(9, max(len(k) for k in snaps)) - for key in sorted(snaps, key=lambda k: (snaps[k].get("unlimited"), k)): - q = snaps[key] or {} - name = pad(key.replace("_", " "), label_w) - if q.get("unlimited"): - # No denominator, so no bar. An unlimited pool drawn as an - # empty gauge invents a limit that was explicitly denied. - rows.append(seg([(DIM, " " + name + " "), - (OK, "unlimited")], w - 1)) - continue - ent = q.get("entitlement") or 0 - used_n = q.get("credits_used") - # percent_remaining is what the API gives; every other tab here - # shows what is spent, and red belongs at the empty end. - pct = 100.0 - float(q.get("percent_remaining") or 0) - used = max(0.0, min(1.0, pct / 100.0)) - # The window is a calendar month, so its length is the gap - # between this reset and the one before it. - span = None - if stamp: - prev = datetime.datetime.fromtimestamp( - stamp, datetime.timezone.utc) - back = (prev.replace(year=prev.year - 1, month=12) - if prev.month == 1 else prev.replace(month=prev.month - 1)) - span = stamp - back.timestamp() - rows.append(seg([(DIM, " " + name + " ")] - + paced_bar(used, elapsed_of(span, stamp), - max(8, w - 38 - label_w), - AGENT_HUE["copilot"]) - + [(pct_colour(pct, ahead_of(lead(pct, span, stamp)), - AGENT_HUE["copilot"]), - pct_text(pct)), - pace_cell(lead(pct, span, stamp))], w - 1)) - # A pool can carry its own reset, in which case it is not on the - # account-wide cycle in the header and has to say so itself. - own = q.get("quota_reset_at") or 0 - own_when = "" - if own and abs(own - (stamp or 0)) > 3600: - left_s = own - time.time() - own_when = (" resets in " + left_span(left_s) - if left_s > 0 else " resetting") - if ent: - left_part = [(DIM, " · "), - (TXT, "{:,}".format(int(q.get("remaining") or 0))), - (DIM, " left")] - if label_w + 34 > w - 1: # no room for the remainder - left_part = [] - rows.append(seg([(DIM, " " + " " * label_w + " "), - (TXT, "{:,}".format(int(used_n or 0))), - (DIM, " of "), (TXT, "{:,}".format(int(ent)))] - + left_part + [ - (WARN, " %d over" % q["overage_count"] - if q.get("overage_count") else ""), - (DIM, own_when)], w - 1)) - rows.append("") - elif state.get("live_why"): - rows.append(seg([(WARN, " no quota: " + state["live_why"])], w - 1)) - rows.append("") - - use = state.get("usage") - if use: - rows.append(seg([(LBL, " ── SPENT ── "), - (DIM, "%d turns across %d sessions" - % (state["events"], state["sessions"]))], w - 1)) - cells = [("input tokens", big_num(use["input"]), TXT), - ("output tokens", big_num(use["output"]), AGENT), - ("cache read", big_num(use["cache"]), DIM), - ("reasoning tokens", big_num(use["reasoning"]), TXT), - # total_nano_aiu is billionths of an AI unit - ("AI units", "%.3f" % (use["nano_aiu"] / 1e9), TXT), - ("time generating", span_ms(use["ms"]), DIM)] - label_w = max(len(c[0]) for c in cells) - ncols = 2 if (w - 2) // 2 - label_w - 3 >= 8 else 1 - cw = (w - 2) // ncols - val_w = max(5, cw - label_w - 3) - for i in range(0, len(cells), ncols): - line = [(RST, " ")] - for lab, value, colour in cells[i:i + ncols]: - line += [(DIM, " " + pad(lab, label_w) + " "), - (colour, pad(value, val_w))] - rows.append(seg(line, w - 1)) - rows.append("") - # The one agent that measures this rather than leaving it to be - # inferred from timestamps, which is why it is stated flatly. - rows.append(seg([(LBL, " ── LATENCY ── "), - (DIM, "measured by Copilot, not inferred")], w - 1)) - rows.append(seg([(DIM, " first token "), - (TXT, "%.0f ms" % use["ttft"]), - (DIM, " between tokens "), - (TXT, "%.1f ms" % use["itl"])], w - 1)) - rows.append("") - models = state.get("models") or [] - if models: - rows.append(seg([(LBL, " ── BY MODEL ── "), - (DIM, "output tokens")], w - 1)) - for model, n, out_tok, _in, _cr, _cw in models: - rows.append(seg([(TXT, " " + pad(str(model or "?"), 28)), - (DIM, "%5d turns " % n), - (AGENT, big_num(out_tok))], w - 1)) - else: - rows.append(seg([(LBL, " ── SPENT ── "), - (DIM, "no local sessions")], w - 1)) - rows.append("") - rows += no_local("Nothing recorded in the local session store yet.", - RUN_HINT["copilot"], w) - return rows - - -class Store(object): - def __init__(self): - self.lock = threading.Lock() - self.claude = {} - self.cursor = {} - self.codex = {} - self.grok = {} - self.copilot = {} - self.antigravity = {} - self.installed = {} - self.error = None - self.fetched = 0 - self.wake = threading.Event() - - def snapshot(self): - with self.lock: - return (dict(self.claude), dict(self.cursor), dict(self.codex), - dict(self.grok), dict(self.copilot), - dict(self.antigravity), dict(self.installed), - self.fetched, self.error) - - def run(self): - # A daemon thread that raises just stops, and a dead poller looks - # exactly like a source with no data - which is how deployments.py - # showed "0 deploys" for a day after an import went missing. - try: - self.poll() - except Exception as e: - with self.lock: - self.error = "poller stopped: %s: %s" % (type(e).__name__, - str(e)[:70]) - - def poll(self): - while True: - claude, cursor, codex = read_claude(), read_cursor(), read_codex() - grok, copilot = read_grok(), read_copilot() - antigravity = read_antigravity() - found = detect_agents() - with self.lock: - self.claude, self.cursor, self.codex = claude, cursor, codex - self.grok, self.copilot = grok, copilot - self.antigravity = antigravity - self.installed = found - self.fetched = time.time() - self.wake.wait(REFRESH) - self.wake.clear() - - -# What we know how to read, and how to tell it is here. An agent counts as -# present if its CLI is on PATH *or* it has left state behind: an uninstalled -# agent whose history is still on disk is worth showing, and a CLI installed -# under a different name would otherwise vanish. -AGENTS = { - "claude": {"label": "Claude Code", "bins": ("claude",), - "paths": (CLAUDE_STATS,)}, - "codex": {"label": "OpenAI Codex", "bins": ("codex",), - "paths": (os.path.expanduser("~/.codex/sessions"),)}, - "cursor": {"label": "Cursor", "bins": ("cursor-agent", "cursor"), - "paths": (CURSOR_DB,)}, - "grok": {"label": "Grok", "bins": ("grok",), - "paths": (os.path.expanduser("~/.grok"),)}, - "copilot": {"label": "GitHub Copilot", "bins": ("copilot",), - "paths": (COPILOT_DB, COPILOT_CONFIG)}, - # No binary on PATH to look for: the CLI is launched by the IDE and its - # server is fetched per run, so the state directory is the only proof it - # is here - which is exactly why detection takes paths as well as bins. - "antigravity": {"label": "Antigravity", "bins": ("antigravity",), - "paths": (ANTIGRAVITY_DIR,)}, -} -ORDER = ("claude", "codex", "cursor", "grok", "copilot", "antigravity") - - -def detect_agents(): - """Which agents this machine has, by binary or by leftover state.""" - found = {} - for name, spec in AGENTS.items(): - binary = next((b for b in spec["bins"] if shutil.which(b)), None) - data = next((p for p in spec["paths"] if os.path.exists(p)), None) - found[name] = {"bin": binary, "data": data, - "present": bool(binary or data)} - return found - - -SUMMARY_TAB = "+" -SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" - - -def loading_rows(w, tick): - """What to show before the first poll lands. - - Every tab's empty state is a statement of fact - no stats cache, no - rollouts, no agent publishing a quota - and each of them is false while - the first read is still running. The first read is also the slow one: - 520MB of Claude transcripts and five pages of Cursor events, some fifteen - seconds of it, which is more than long enough for a wrong answer to be - read and believed. - """ - rows = [seg([(ACCENT, " " + SPINNER[tick % len(SPINNER)]), - (TXT, " reading local state and quotas")], w - 1), ""] - for line in ("The first pass is the slow one: Claude's transcripts run to" - " hundreds of megabytes and Cursor's usage events are paged" - " a thousand at a time. Both are cached afterwards.",): - for part in wrap_text(line, max(20, w - 4)): - rows.append(seg([(DIM, " " + part)], w - 1)) - return rows - - -def elapsed_of(secs, reset): - """How much of a window has gone, from its length and its reset.""" - if not secs or not reset: - return None - left = reset - time.time() - if left <= 0 or left > secs: - return None - return (secs - left) / secs - - -# The dark end of every agent ramp. The two stops above it are measured, not -# picked: 0.51 keeps the dimmest filled cell at 3:1 against the background for -# the darkest agent hue, and 0.34 leaves the empty track at least as visible -# as the flat GRID it replaces (2.20:1 against 2.16:1). -BAR_FLOOR = (30, 38, 52) -# The notch is white on its own dark cell rather than a bare foreground -# colour. Plain white manages 1.2:1 against a full bar - the agent hues are -# light themselves, so it disappears exactly where it matters - while giving -# the cell a dark background makes the mark read the same on a full bar, an -# empty track, or the boundary between them. -PACE_MARK = bg(10, 12, 18) + rgb(238, 244, 252) -NOBG = "\x1b[49m" # default background again, and only that - - -def blend(hue, t): - """A tint of an agent's colour as a tuple, t running dark to full.""" - return tuple(int(round(BAR_FLOOR[i] + (hue[i] - BAR_FLOOR[i]) * t)) - for i in range(3)) - - -def tint(hue, t): - """A tint of an agent's colour, t running dark to full. - - Not `shade` - that name is taken by the calendars' four-step ramp, and - two functions of the same name meant the heatmaps drew with this one. - """ - return rgb(*blend(hue, t)) - - -def paced_bar(used, elapsed, room, hue=None): - """A quota bar with a mark where an even burn would have reached by now. - - The percentage alone cannot separate a lane that is 71% spent with three - weeks left from one that is 71% spent with three days left, and colour - alone cannot either - both are the same red. The mark is the window's - own progress, so a fill short of it is spending slower than the clock - and a fill past it is not. - - Returned as coloured segments rather than a string because the mark has - to be tinted against whichever side of it the fill lands on, and it is a - different glyph from the bar so it survives without colour at all. - """ - bar = meter(used, room) - filled = bar.count("█") - at = None - if elapsed is not None: - at = max(0, min(room - 1, int(round(elapsed * room)))) - parts = [] - for i, ch in enumerate(bar): - if i == at: - # One colour for the mark on every bar. It is a reference line - - # where an even burn would have reached - and a line that changes - # colour looks like it has a state of its own, when the state - # being reported is the fill's position relative to it. Copilot - # in amber beside five in green read as Copilot's mark meaning - # something different, rather than Copilot being behind. - # - # Near-black rather than a light neutral: the agent hues are - # themselves light, so white vanishes inside a full bar (1.04:1) - # while this holds 8.1:1 there and 2.5:1 on the empty track. - parts.append((PACE_MARK, "┃")) - elif i < filled: - # Filled cells run dark to full across the fill, so the bar is - # recognisably its agent's colour and still reads as a quantity - # without counting cells. - t = 0.51 + 0.49 * (i / float(max(1, filled - 1))) - parts.append((NOBG + (tint(hue, t) if hue else heat(used)), ch)) - else: - parts.append((NOBG + (tint(hue, 0.34) if hue else GRID), ch)) - # Runs of one colour are merged so a row is a handful of escapes rather - # than one per cell. - merged = [] - for colour, ch in parts: - if merged and merged[-1][0] == colour: - merged[-1] = (colour, merged[-1][1] + ch) - else: - merged.append((colour, ch)) - # The mark is the one thing here that sets a background, so the run ends - # by putting it back. Without this the dark cell bled through everything - # drawn after it on the row - the rest of the bar, the percentage, the - # reset - which is exactly what a stray background looks like: broken. - return merged + [(NOBG, "")] - - -def summary_tab(states, w, h): - """Every agent's quotas on one screen, worst first. - - Not a concatenation of the other tabs: those answer "how am I using this - agent", and this answers the only question that spans them - what runs - out first. So the lanes are ranked by what is spent rather than grouped - by agent, and an agent that publishes no quota is named at the bottom - instead of being silently missing. - """ - lanes, quiet = [], [] - for name in ORDER: - got = quota_lanes(name, states.get(name) or {}) - if got: - lanes.extend((name,) + tuple(lane) + (False,) * (5 - len(lane)) - for lane in got) - else: - quiet.append(name) - if not lanes: - return no_local("No agent is publishing a quota right now.", "", w) - # Grouped by provider, but the groups are ordered by their worst lane and - # so are the lanes inside them: the structure says who owns what, the - # ordering still answers which one runs out first. - groups = {} - for lane in lanes: - groups.setdefault(lane[0], []).append(lane) - order = sorted(groups, key=lambda n: -max(x[2] for x in groups[n])) - label_w = min(16, max(len(x[1]) for x in lanes)) - head = "%d limits across %d agents" % (len(lanes), - len(ORDER) - len(quiet)) - # Sized against the suffix actually being added, so changing the wording - # cannot quietly start clipping the line. - suffix = " · ranked by usage" - if 14 + len(head) + len(suffix) <= w - 1: - head += suffix - rows = [seg([(LBL, " ── QUOTAS ── "), (DIM, head)], w - 1)] - # 2 lead + label + 1 + pct(6) + pace(6). The reset needs 16 more and is - # the first thing dropped, being the only part a reader can infer from - # the bar beside it - but a stale marker is not droppable, since a number - # nobody labelled as old reads as current. - fixed = 15 + label_w - show_reset = (w - 1) - fixed - 8 >= 16 - tail = 16 if show_reset else (8 if any(x[5] for x in lanes) else 0) - bar_room = max(8, (w - 1) - fixed - tail) - for i, name in enumerate(order): - if i: - rows.append("") - rows.append(seg([(rgb(*AGENT_HUE.get(name, (225, 235, 245))), - " " + name.upper())], w - 1)) - # Ranked by usage, except where the lanes nest. Claude's five-hour - # window sits inside its weekly one, which contains the model-scoped - # limit in turn, and reading them widest-last says more than reading - # them by percentage - which also reorders itself as the numbers - # move, so the bar under the cursor is not the one that was there a - # refresh ago. - inner = (groups[name] if name == "claude" - else sorted(groups[name], key=lambda x: -x[2])) - for _, label, pct, secs, reset, stale in inner: - used = max(0.0, min(1.0, pct / 100.0)) - gone = None - if secs and reset: - left = reset - time.time() - if 0 < left <= secs: - gone = (secs - left) / secs - when, tint = "", DIM - if stale: - when, tint = " cached", WARN - elif show_reset and reset: - left = reset - time.time() - when = (" " + left_span(left)) if left > 0 else " resetting" - rows.append(seg([(DIM, " " + pad(label, label_w) + " ")] - + paced_bar(used, gone, bar_room, - AGENT_HUE.get(name)) - + [(pct_colour(pct, ahead_of(lead(pct, secs, reset)), - AGENT_HUE.get(name)), - pct_text(pct)), - pace_cell(lead(pct, secs, reset)), - (tint, when)], w - 1)) - if quiet: - rows.append("") - rows += no_local("No quota published by: " + ", ".join(quiet) - + ".", "", w) - return rows - - -def quota_lanes(name, state): - """Every quota an agent publishes, flattened to (label, pct, window, reset). - - Read here rather than borrowed from each tab's renderer, because those - render six different shapes - Cursor's coloured lanes, Antigravity's - groups, Codex's per-feature windows - and only the four numbers below are - common to all of them. The field names are the same ones the tabs use, so - a change to an API shows up in both places at once. - """ - lanes = [] - if name == "claude": - # A cached reading can describe windows that have since closed, so - # its lanes are marked rather than counted down to - the agent's own - # tab says "cached 18h ago" and this must not quietly disagree. - stale = (state.get("quota") or {}).get("source") != "live" - q = (state.get("quota") or {}).get("u") or {} - for l in sorted(q.get("limits") or [], key=claude_lane_rank): - if l.get("percent") is None: - continue - scope = ((l.get("scope") or {}).get("model") or {}).get( - "display_name") - group = l.get("group") or "" - label = scope or ("session" if l.get("kind") == "session" - else "overall") - lanes.append(("%s %s" % (label, CLAUDE_WINDOW.get(group, "")), - float(l["percent"]), - None if stale else CLAUDE_WINDOW_SECS.get(group), - None if stale else iso_epoch(l.get("resets_at")), - stale)) - elif name == "codex": - live = state.get("live") or {} - for key in ("primary_window", "secondary_window"): - win = (live.get("rate_limit") or {}).get(key) - if win and win.get("used_percent") is not None: - secs = win.get("limit_window_seconds") - lanes.append((window_name(secs), float(win["used_percent"]), - secs, win.get("reset_at"))) - for extra in live.get("additional_rate_limits") or []: - win = (extra.get("rate_limit") or {}).get("primary_window") or {} - if win.get("used_percent") is None: - continue - short = (extra.get("limit_name") or "?").rsplit("-", 1)[-1] - secs = win.get("limit_window_seconds") - lanes.append(("%s %s" % (short, window_name(secs)), - float(win["used_percent"]), secs, - win.get("reset_at"))) - elif name == "cursor": - live = state.get("live") or {} - plan = live.get("planUsage") or {} - start, end = live.get("billingCycleStart"), live.get("billingCycleEnd") - secs = reset = None - if start and end and int(end) > int(start): - secs = (int(end) - int(start)) / 1000.0 - reset = int(end) / 1000.0 - for label, key in (("included", "totalPercentUsed"), - ("auto", "autoPercentUsed"), - ("api", "apiPercentUsed")): - if plan.get(key) is not None: - lanes.append((label, float(plan[key]), secs, reset)) - elif name == "grok": - q = state.get("quota") or {} - if q.get("percent") is not None: - begin, end = iso_epoch(q.get("start") or ""), iso_epoch(q.get("end") or "") - secs = (end - begin) if (begin and end and end > begin) else None - lanes.append(("credits", float(q["percent"]), secs, end)) - elif name == "copilot": - live = state.get("live") or {} - stamp = iso_epoch((live.get("quota_reset_date_utc") or "")) - span = None - if stamp: - end = datetime.datetime.fromtimestamp(stamp, datetime.timezone.utc) - back = (end.replace(year=end.year - 1, month=12) if end.month == 1 - else end.replace(month=end.month - 1)) - span = stamp - back.timestamp() - for key, snap in (live.get("quota_snapshots") or {}).items(): - if (snap or {}).get("unlimited") or snap.get("percent_remaining") is None: - continue - lanes.append((key.replace("_", " ").replace("interactions", "reqs"), - 100.0 - float(snap["percent_remaining"]), span, stamp)) - elif name == "antigravity": - for group in state.get("quota") or []: - short = str(group.get("displayName") or "?").split()[0].lower() - for b in group.get("buckets") or []: - if b.get("remainingFraction") is None: - continue - window = str(b.get("window") or "?") - lanes.append(("%s %s" % (short, window), - 100.0 * (1.0 - float(b["remainingFraction"])), - ANTIGRAVITY_WINDOWS.get(window), - iso_epoch(b.get("resetTime") or ""))) - return lanes - - -def window_name(secs): - secs = int(secs or 0) - if secs >= 86400: - return "%dd" % (secs // 86400) - return "%dh" % (secs // 3600) if secs else "?" - - -def visible_agents(found): - """The tabs to draw. - - Empty `agents` discovers: every agent this machine actually has. Naming - them instead fixes both the set and the order, whether or not they are - installed - if you listed it, you want the tab. `exclude_agents` drops one - either way. - - Falls back to everything known if the result would be empty, because a - widget with no tabs teaches nothing and the likeliest cause is a typo in - the config rather than a machine with no agents on it. - """ - drop = set(_CFG["exclude_agents"] or []) - named = [n for n in (_CFG["agents"] or []) if n in AGENTS] - chosen = named or [n for n in ORDER if found.get(n, {}).get("present")] - shown = tuple(n for n in chosen if n not in drop) - # The summary leads and is never discovered or excluded: it is not an - # agent, it is the view across whichever agents there turn out to be. - return (SUMMARY_TAB,) + (shown or ORDER) - - -def config_complaints(found): - """Names in the config that match no agent we know how to read.""" - known = set(AGENTS) - bad = [n for n in (list(_CFG["agents"] or []) - + list(_CFG["exclude_agents"] or [])) if n not in known] - return ("unknown agent in config: %s (known: %s)" - % (", ".join(sorted(set(bad))), ", ".join(ORDER))) if bad else None - - -def tab_bar(active, installed, tabs, w): - # brackets as well as the tint: which tab is open must not depend on a - # background colour surviving. A dot marks an agent that is installed. - parts = [(RST, " ")] - for name in tabs: - here = name == active - have = (installed.get(name) or {}).get("present", False) - parts.append((bg(38, 56, 76) + ACCENT if here else DIM, - ("[%s]" if here else " %s ") % name.upper())) - if name == SUMMARY_TAB: - parts.append((GRID, " ")) - continue - parts.append((OK if have else GRID, "·" if have else " ")) - return seg(parts, w - 1) - - -WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") - - -MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") - - -def token_heatmap(daily, w): - """Claude's shape: a list of {date, tokensByModel} records.""" - return day_calendar( - dict((e["date"], sum((e.get("tokensByModel") or {}).values())) - for e in daily if e.get("date")), w) - - -def day_calendar(totals_by_date, w, steps=HEAT_STEPS, weeks=None): - """Tokens per day, drawn the way Claude Code's own /stats draws it. - - Weekday rows with only Mon, Wed and Fri labelled; one cell per day; months - named across the top; solid blocks in four steps of a single hue, and a dim - dot for a day the file has no entry for. - - The grid spans the whole window even where there is no data, because the - emptiness is information: this cache retains about four weeks, and a year - of dots with a fortnight of colour at the right-hand end says that plainly. - """ - totals = {} - for key, value in (totals_by_date or {}).items(): - try: - totals[datetime.date.fromisoformat(key)] = value - except (ValueError, TypeError): - continue - if not totals: - return [], 0, None, {} - peak = max(totals.values()) or 1 - best = max(totals, key=totals.get) - - last = max(totals) - # A caller with a bounded window says so, rather than having its month - # of data stretched across a year of empty dots. Left unset the grid - # fills the pane, which is right for a cache whose emptiness is itself - # information. - weeks_fit = max(4, min(w - 7, weeks or w - 7)) - end_week = last - datetime.timedelta(days=last.weekday()) - starts = [end_week - datetime.timedelta(days=7 * i) - for i in range(weeks_fit - 1, -1, -1)] - - # month names sit over the week their month starts in, three characters - # wide like /stats - a single initial is not a label, it is a hint - # a month label needs three clear cells; without checking where the last - # one ended, a short month writes over its neighbour and produces "JJul" - strip = [" "] * len(starts) - seen, wrote_to = None, -1 - for x, wk in enumerate(starts): - if wk.month != seen and x > wrote_to and x + 3 <= len(strip): - seen = wk.month - for k, ch in enumerate(MONTHS[wk.month - 1]): - strip[x + k] = ch - wrote_to = x + 3 - rows = [[(DIM, " " + "".join(strip))]] - for i in range(7): - label = {0: "Mon", 2: "Wed", 4: "Fri"}.get(i, "") - line = [(DIM, " %-4s" % label)] - for wk in starts: - day = wk + datetime.timedelta(days=i) - n = totals.get(day) - if n is None: - line.append((EMPTY_CELL, "·")) - else: - line.append((shade((n / float(peak)) ** 0.5, steps), "█")) - rows.append(line) - - # active out of days in the range, not out of days the file happens to - # list - otherwise every day is active by construction and the ratio says - # nothing - span = (max(totals) - min(totals)).days + 1 - active = sum(1 for v in totals.values() if v) - run = best_run = 0 - for i in range(span): - day = min(totals) + datetime.timedelta(days=i) - run = run + 1 if totals.get(day) else 0 - best_run = max(best_run, run) - current = 0 - for i in range(span): - day = max(totals) - datetime.timedelta(days=i) - if not totals.get(day): - break - current += 1 - facts = {"active": active, "span": span, - "longest": best_run, "current": current} - return rows, peak, best, facts - - -def stats_lag(data, room): - """How far behind today the stats cache's own reckoning is. - - `room` is the columns actually left on the line, measured by the caller - rather than guessed from the pane width - the text before this varies, - so a width threshold clipped at some widths and not others. The longest - phrasing that fits wins; the day count is the half that must survive. - """ - last = (data or {}).get("lastComputedDate") or "" - try: - when = datetime.date.fromisoformat(last) - except (ValueError, TypeError): - return "" - days = (datetime.date.today() - when).days - if days <= 0: - return "" - for text in (" cache is %dd behind, to %s" - % (days, when.strftime("%b %-d")), - " cache %dd behind" % days, - " -%dd" % days): - if len(text) <= room: - return text - return "" - - -def claude_metered(state, w): - daily = state.get("daily_models") or {} - return metered_rows([("today", window_models(daily, 1)), - ("30 days", window_models(daily, 30))], w, - agent="claude", scope="this machine", - caveat="Counted from transcripts, which are written" - " where the agent ran. Claude used on another" - " machine, or on claude.ai, is not in here.") - - -def claude_tab(state, w, h): - rows = claude_quota(state.get("quota"), w) - if not state.get("ok"): - return rows + no_local("No stats cache yet (%s)." - % state.get("why"), - RUN_HINT["claude"], w) - d = state["data"] - mu = d.get("modelUsage") or {} - daily = d.get("dailyActivity") or [] - - out_tok = sum(v.get("outputTokens") or 0 for v in mu.values()) - in_tok = sum(v.get("inputTokens") or 0 for v in mu.values()) - cache_r = sum(v.get("cacheReadInputTokens") or 0 for v in mu.values()) - cache_w = sum(v.get("cacheCreationInputTokens") or 0 for v in mu.values()) - - # the heatmap is computed first: its streaks and active-day counts belong - # in the summary above it, not only beside the calendar - grid, peak, best, facts = token_heatmap(d.get("dailyModelTokens") or [], w) - - ls = d.get("longestSession") or {} - fav = max(mu, key=lambda k: mu[k].get("outputTokens") or 0) if mu else "—" - all_tokens = in_tok + out_tok + cache_r + cache_w - rows.append(seg([(LBL, " ── SUMMARY ── "), - (DIM, "all time · since %s" - % (d.get("firstSessionDate") or "")[:10])], w - 1)) - pairs = [ - ("Favorite model", fav.replace("claude-", ""), AGENT, - "Total tokens", big_num(all_tokens), AGENT), - ("Sessions", "%d" % (d.get("totalSessions") or 0), TXT, - "Longest session", span_ms(ls.get("duration")), TXT), - ("Active days", "%d/%d" % (facts.get("active", 0), facts.get("span", 0)) - if facts else "—", TXT, - "Longest streak", "%d days" % facts.get("longest", 0) if facts else "—", - TXT), - ("Most active day", best.strftime("%b %-d") if best else "—", TXT, - "Current streak", "%d days" % facts.get("current", 0) if facts else "—", - OK if facts.get("current") else DIM), - ] - lw = max(max(len(a), len(c)) for a, _b, _bc, c, _d2, _dc in pairs) - half = (w - 3) // 2 - vw = max(6, half - lw - 2) - for a, b, bc, c, e, ec in pairs: - rows.append(seg([(DIM, " " + pad(a, lw) + " "), (bc, pad(b, vw)), - (DIM, " " + pad(c, lw) + " "), (ec, pad(e, vw))], - w - 1)) - rows.append(seg([(DIM, " Input "), (TXT, big_num(in_tok)), - (DIM, " · Output "), (TXT, big_num(out_tok)), - (DIM, " · Cache read "), (TXT, big_num(cache_r)), - (DIM, " · Cache written "), (TXT, big_num(cache_w))], - w - 1)) - # which model did the work - rows.append("") - ranked = sorted(mu.items(), key=lambda kv: -(kv[1].get("outputTokens") or 0)) - ranked = [(k, v) for k, v in ranked if (v.get("outputTokens") or 0) > 0] - rows.append(seg([(LBL, " ── BY MODEL ── "), - (DIM, "output tokens")], w - 1)) - if ranked: - top = ranked[0][1].get("outputTokens") or 1 - for name, v in ranked[:5]: - tok = v.get("outputTokens") or 0 - share = tok / float(top) - bar = meter(share, max(6, w - 34)) - filled = bar.count("█") - rows.append(seg([(TXT, " " + pad(name.replace("claude-", ""), 20)), - (AGENT, "%7s " % big_num(tok)), - (AGENT, bar[:filled]), (GRID, bar[filled:])], - w - 1)) - - # 26 days of activity, straight from the file - if daily: - rows.append("") - counts = [x.get("messageCount") or 0 for x in daily] - peak = max(counts) or 1 - # Both charts on this tab come from stats-cache.json, which Claude - # Code recomputes on its own schedule - lastComputedDate can sit a - # day or two back. Unlabelled, that gap reads as idle days rather - # than as days the cache has not caught up with. - head, tail = " ── MESSAGES / DAY ── ", "%dd · peak %s" % ( - len(daily), f"{peak:,}") - rows.append(seg([(LBL, head), (DIM, tail), - (WARN, stats_lag(d, w - 1 - len(head) - len(tail)))], - w - 1)) - avail = max(10, w - 3) - cols = [] - for c, wide in zip(counts, spread(len(counts), avail)): - cols.extend([(c, AGENT)] * wide) - for line in vbars(cols, 3): - rows.append(seg([(RST, " ")] + line, w - 1)) - rows.append(seg([(RST, " "), (GRID, "─" * len(cols))], w - 1)) - left = daily[0].get("date", "")[5:] - right = daily[-1].get("date", "")[5:] - rows.append(seg([(DIM, " " + left), - (DIM, " " * max(1, len(cols) - len(left) - len(right))), - (DIM, right)], w - 1)) - - # ── how fast it generates ─────────────────────────────────────────── - rates = state.get("rates") or [] - if rates: - med = rates[len(rates) // 2] - p90 = rates[min(len(rates) - 1, int(len(rates) * 0.9))] - rows.append("") - rows.append(seg([(LBL, " ── OUTPUT RATE ── "), - (DIM, "%d turns across %d transcripts" - % (len(rates), state.get("sampled", 0)))], w - 1)) - rows.append(seg([(DIM, " median "), (AGENT, "%.0f" % med), - (DIM, " tok/s p90 "), (TXT, "%.0f" % p90), - (DIM, " request to response, tools included")], - w - 1)) - - # ── tokens per day, as a calendar ─────────────────────────────────── - if grid: - rows.append("") - head = " ── TOKENS / DAY ── peak " - tail = "%s on %s" % (big_num(peak), - best.strftime("%b %-d") if best else "--") - rows.append(seg([(LBL, " ── TOKENS / DAY ── "), - (DIM, "peak "), (AGENT, big_num(peak)), - (DIM, " on %s" % (best.strftime("%b %-d") if best - else "--")), - (WARN, stats_lag(d, w - 1 - len(head) - - len(tail)))], w - 1)) - for line in grid: - rows.append(seg(line, w - 1)) - rows.append(seg([(DIM, " Less ")] - + [(rgb(*c), "█") for c in HEAT_STEPS] - + [(DIM, " More")], w - 1)) - - - return rows - - -def cursor_quota(live, w): - """The three lanes cursor-agent's Usage view shows, plus the cycle.""" - plan = (live or {}).get("planUsage") or {} - if not plan: - return [] - rows = [] - ends = live.get("billingCycleEnd") - when = "" - if ends: - left = int(ends) / 1000.0 - time.time() - when = ("resets in %dd" % (left // 86400)) if left > 0 else "resetting" - start = live.get("billingCycleStart") - elapsed, cycle_secs, reset_ts = None, None, None - if start and ends and int(ends) > int(start): - cycle_secs = (int(ends) - int(start)) / 1000.0 - reset_ts = int(ends) / 1000.0 - gone = time.time() * 1000 - int(start) - elapsed = max(0.0, min(100.0, 100.0 * gone / (int(ends) - int(start)))) - rows.append(seg([(LBL, " ── QUOTA ── "), (OK, "live"), - (DIM, scope_phrase(w, 17 + len(when))), - (DIM, when)], w - 1)) - if elapsed is not None: - # Just the fact; the +/- column it explains is on every tab now, so - # a per-tab legend was both redundant and the thing that clipped. - rows.append(seg([(DIM, " %.0f%% of the cycle gone" % elapsed)], - w - 1)) - values = {"included": plan.get("totalPercentUsed"), - "auto": plan.get("autoPercentUsed"), - "api": plan.get("apiPercentUsed")} - for name, stop in CURSOR_LANE_STOPS: - pct = values.get(name) - if pct is None: - continue - used = max(0.0, min(1.0, pct / 100.0)) - hue = blend(AGENT_HUE["cursor"], stop) - # How far ahead of the clock you are: the share of the billing period - # already gone, minus the share of the allowance spent. Positive is a - # cushion, negative means this lane runs out before the cycle does. - # It is what CodexBar calls "in reserve", and it is pure arithmetic on - # the cycle dates - nothing new is fetched for it. - rows.append(seg([(DIM, " %-9s" % name)] - + paced_bar(used, elapsed_of(cycle_secs, reset_ts), - max(8, w - 40), hue) - + [(pct_colour(pct, ahead_of(lead(pct, cycle_secs, - reset_ts)), hue), - pct_text(pct)), - pace_cell(lead(pct, cycle_secs, reset_ts))], w - 1)) - limit, spend = plan.get("limit"), plan.get("totalSpend") - if limit: - # Deliberately dollars rather than a fourth bar. This is spend against - # the plan limit - a different denominator from the three lanes above, - # which are the server's own percentages - and drawing it as a bar - # beside them would invite reading 12% and 2% as the same scale. - left = plan.get("remaining") - rows.append(seg([(DIM, " spend "), - (TXT, "$%.2f" % ((spend or 0) / 100.0)), - (DIM, " of "), (TXT, "$%.2f" % (limit / 100.0)), - (DIM, " $%.2f left" % (left / 100.0) - if left is not None else "")], w - 1)) - rows.append("") - return rows - - -def cursor_metered_rows(state, w): - """What Cursor charges, beside what the same traffic costs at list. - - Cursor is the one agent that publishes both, so no rate card is needed: - GetAggregatedUsageEvents returns what it meters against the plan, and - the raw events carry their own vendor-rate cents. The gap is what the - subscription is worth, and every event states its own discount. - """ - ev = state.get("events") or {} - spend = state.get("spend") or {} - metered = float(spend.get("totalCostCents") or 0) - by = ev.get("by_day_model") or {} - if not by and not metered: - return [] - - def window(days): - first = (datetime.date.today() - - datetime.timedelta(days=days - 1)).isoformat() - cents, tokens, models = 0.0, 0, {} - for day, entries in by.items(): - if day < first: - continue - for model, got in entries.items(): - cents += got["cents"] - tokens += got["tokens"] - models[model] = models.get(model, 0.0) + got["cents"] - - return (cents / 100.0, tokens, - sorted([(m, c / 100.0) for m, c in models.items()], - key=lambda x: -x[1])) - - windows = [] - for label, days in (("today", 1), ("30 days", ev.get("days") or 30)): - cost, tokens, models = window(days) - windows.append((label, cost, tokens, models)) - vendor = float(ev.get("vendor_cents") or 0) - saves = (vendor - metered) / 100.0 if vendor and metered else None - return metered_block("vendor rates", windows, w, scope="account-wide", - extras=[("cursor meters", metered / 100.0 if metered - else None, TXT), - ("the plan saves", saves, OK)], - caveat="From Cursor's own API, so it covers every" - " device on the account, not just this one.") - - -def cursor_daily_rows(events, w): - """Spend per day, one column per day, the way the usage page shows it. - - A bar chart rather than the calendar the token tabs use: this window is - thirty days, and thirty cells of a year-wide grid is six columns of - colour in a field of dots. Money over a month reads better as a profile, - and it is the shape Cursor's own dashboard draws. - """ - by_day = (events or {}).get("by_day") or {} - if not by_day: - return [] - days = events.get("days") or 30 - today = datetime.date.today() - series = [(today - datetime.timedelta(days=n)) for n in range(days - 1, -1, -1)] - cents = [by_day.get(d, 0.0) for d in series] - peak = max(cents) or 1.0 - best = series[cents.index(peak)] if peak in cents else None - rows = [seg([(LBL, " ── SPEND / DAY ── "), - (DIM, "%dd · peak " % days), (AGENT, "$%.0f" % (peak / 100.0)), - (DIM, " on %s" % (best.strftime("%b %-d") if best else "--")), - (DIM, " · today "), (TXT, "$%.2f" - % (by_day.get(today, 0.0) / 100.0))], - w - 1)] - avail = max(10, w - 3) - cols = [] - for c, wide in zip(cents, spread(len(cents), avail)): - cols.extend([(c, AGENT)] * wide) - for line in vbars(cols, 3, hi=peak): - rows.append(seg([(RST, " ")] + line, w - 1)) - rows.append(seg([(RST, " "), (GRID, "─" * len(cols))], w - 1)) - left, right = series[0].strftime("%b %-d"), series[-1].strftime("%b %-d") - rows.append(seg([(DIM, " " + left), - (DIM, " " * max(1, len(cols) - len(left) - len(right))), - (DIM, right)], w - 1)) - rows.append("") - return rows - - -def cursor_spend_rows(spend, w): - """Where the money went, per model, over the last 30 days.""" - if not spend or not spend.get("aggregations"): - return [] - rows = [seg([(LBL, " ── SPEND ── "), (DIM, "last 30d · "), - (AGENT, "$%.2f" % (float(spend.get("totalCostCents") or 0) / 100)), - (DIM, " in "), (TXT, big_num(int(spend.get("totalInputTokens") or 0))), - (DIM, " · out "), (TXT, big_num(int(spend.get("totalOutputTokens") or 0))), - (DIM, " · cache "), - (TXT, big_num(int(spend.get("totalCacheReadTokens") or 0)))], - w - 1)] - models = sorted(spend["aggregations"], - key=lambda a: -float(a.get("totalCents") or 0)) - top = float(models[0].get("totalCents") or 1) - for a in models[:6]: - cents = float(a.get("totalCents") or 0) - bar = meter(cents / top if top else 0, max(6, w - 44)) - filled = bar.count("█") - rows.append(seg([(TXT, " " + pad(str(a.get("modelIntent") or "?"), 26)), - (AGENT, "%9s " % ("$%.2f" % (cents / 100))), - (AGENT, bar[:filled]), (GRID, bar[filled:])], w - 1)) - rows.append("") - return rows - - -def cursor_tab(state, w, h): - if not state.get("ok"): - return (cursor_quota(state.get("live"), w) - + cursor_spend_rows(state.get("spend"), w) - or no_local("No Cursor tracking database on this machine.", - RUN_HINT["cursor"], w)) - rows = cursor_quota(state.get("live"), w) - rows += cursor_daily_rows(state.get("events"), w) - rows += cursor_spend_rows(state.get("spend"), w) - rows.append(seg([(LBL, " ── AI-WRITTEN CODE ── "), - (DIM, "last seen %s ago" - % ago((state.get("last") or 0) / 1000.0 - if state.get("last") else None))], w - 1)) - ai, human = state["lines"], state["human_lines"] - total = ai + human - cells = [("tracked edits", f"{state['hashes']:,}", AGENT), - ("conversations", f"{state['conversations']:,}", TXT), - ("scored commits", f"{state['commits']:,}", TXT), - ("lines by agent", f"{ai:,}", AGENT), - ("lines by hand", f"{human:,}", TXT), - ("models used", "%d" % state["models"], DIM)] - label_w = max(len(c[0]) for c in cells) - ncols = 2 if (w - 2) // 2 - label_w - 3 >= 8 else 1 - cw = (w - 2) // ncols - val_w = max(5, cw - label_w - 3) - for i in range(0, len(cells), ncols): - line = [(RST, " ")] - for label, value, colour in cells[i:i + ncols]: - line += [(DIM, " " + pad(label, label_w) + " "), - (colour, pad(value, val_w))] - rows.append(seg(line, w - 1)) - if total: - rows.append("") - rows.append(seg([(LBL, " ── WHO WROTE IT ── "), - (DIM, "%s lines scored" % f"{total:,}")], w - 1)) - rows.append(seg([(RST, " ")] + stacked_bar( - [(ai / float(total), AGENT), (human / float(total), DIM)], - max(10, w - 3)), w - 1)) - rows.append(seg([(AGENT, " ▇ agent %s (%.0f%%)" - % (f"{ai:,}", 100.0 * ai / total)), - (DIM, " ▇ hand %s (%.0f%%)" - % (f"{human:,}", 100.0 * human / total))], w - 1)) - if state["by_model"]: - rows.append("") - rows.append(seg([(LBL, " ── BY MODEL ── "), (DIM, "tracked edits")], - w - 1)) - top = state["by_model"][0][1] or 1 - for name, n in state["by_model"][:5]: - bar = meter(n / float(top), max(6, w - 36)) - filled = bar.count("█") - rows.append(seg([(TXT, " " + pad(str(name or "?"), 22)), - (AGENT, "%7s " % f"{n:,}"), - (AGENT, bar[:filled]), (GRID, bar[filled:])], - w - 1)) - rows.append("") - rows.append(seg([(DIM, " Authorship, not spend: this is how much code the" - " agent wrote,")], w - 1)) - rows.append(seg([(DIM, " which is a different question from what it" - " cost.")], w - 1)) - return rows - - -def elsewhere_tab(name, installed, w, h): - # Every agent in AGENTS now has a reader, so this is a backstop for one - # added without one - it says so rather than raising in the draw loop. - label, lines = ELSEWHERE.get( - name, (name, ["No reader for this agent yet."])) - have = (installed.get(name) or {}).get("present") - rows = [seg([(LBL, " ── %s ── " % label.upper()), - (OK if have else DIM, - "installed" if have else "not installed")], w - 1), ""] - for line in lines: - rows.append(seg([(DIM if line else RST, " " + line)], w - 1)) - rows.append("") - rows.append(seg([(WARN, " Nothing is shown for it because nothing is" - " published.")], w - 1)) - rows.append(seg([(DIM, " A plausible-looking zero would be worse than an" - " empty tab.")], w - 1)) - return rows - - -def main(): - maybe_help(__doc__) - args = sys.argv[1:] - while args and args[0] in ("-n", "--refresh"): - global REFRESH - REFRESH = float(args[1]) - args = args[2:] - store = Store() - threading.Thread(target=store.run, daemon=True).start() - setup() - keyboard = Keyboard() - active = 0 - tick = 0 - # One offset per tab. Switching away and back keeps your place, which - # matters when a tab is forty rows and you were reading the bottom of it. - offsets = {} - - while True: - tick += 1 - # Scrolling is applied after the frame is built, not here: a page is - # however many body rows this pane turned out to have, and that is - # not known until the tab has been rendered and the footer packed. - moves = [] - for key in keyboard.poll(): - if key in ("q", "Q"): - raise SystemExit(0) - if key in ("right", "tab", "l"): - active += 1 - elif key in ("left", "h"): - active -= 1 - elif key in ("up", "k"): - moves.append(-1) - elif key in ("down", "j"): - moves.append(1) - elif key == "pgup": - moves.append("-page") - elif key == "pgdn": - moves.append("+page") - elif key == "home": - moves.append("top") - elif key == "end": - moves.append("bottom") - elif key == "r": - store.wake.set() - - w, h = size() - (claude, cursor, codex, grok, copilot, antigravity, - installed, fetched, err) = store.snapshot() - rows = [title("agent usage", w, AGENT)] - tabs = visible_agents(installed) - active %= len(tabs) # wraps in both directions - extra = [n for n in ORDER - if (installed.get(n) or {}).get("present") and n not in tabs] - def status(where=""): - # The scroll position goes last on this line but matters most, - # so the legend stands down to make room rather than letting it - # be clipped - which is what had been happening at 58 columns, - # leaving the footer offering a scroll with nothing saying where - # in the tab you were. - base = " local state · live quota · read %s ago" % ago(fetched) - hidden = " %d hidden by config" % len(extra) if extra else "" - legend = " · = detected" - if len(base) + len(hidden) + len(legend) + len(where) > w - 1: - legend = "" - return seg([(DIM, base), (DIM, legend), (DIM, hidden), - (ACCENT, where)], w - 1) - - status_at = len(rows) # filled in once the scroll is resolved - rows.append(status()) - gripe = err or config_complaints(installed) - if gripe: - rows.append(seg([(BAD, " ! " + gripe)], w - 1)) - rows.append(tab_bar(tabs[active], installed, tabs, w)) - rows.append("") - - # Every agent ends on the same section, in the same place. Which - # subscription a quota belongs to is context for the whole tab, not - # the headline, so it sits under the numbers it explains - and it is - # appended here rather than by five tabs that each end differently. - name = tabs[active] - sub = [] - if not fetched: - body = loading_rows(w, tick) - elif name == SUMMARY_TAB: - body = summary_tab({"claude": claude, "codex": codex, - "cursor": cursor, "grok": grok, - "copilot": copilot, - "antigravity": antigravity}, w, h) - elif name == "claude": - body = add_section(claude_tab(claude, w, h), - claude_metered(claude, w)) - if claude.get("profile"): - sub = claude_plan_rows(claude["profile"], w) - elif name == "cursor": - # METERED sits last-but-one on every tab, so Cursor's - which - # is published rather than configured - lands in the same place - # as everyone else's rather than floating up beside its quota. - body = add_section(cursor_tab(cursor, w, h), - cursor_metered_rows(cursor, w)) - sub = cursor_plan_rows(cursor.get("plan"), w) - elif name == "codex": - body = add_section(codex_tab(codex, w, h), - codex_metered(codex, w)) - sub = codex_plan_rows(codex, w) - elif name == "grok": - body = grok_tab(grok, w, h) - sub = grok_plan_rows(grok.get("quota"), w) - elif name == "copilot": - body = add_section(copilot_tab(copilot, w, h), - copilot_metered(copilot, w)) - sub = copilot_plan_rows(copilot.get("live") or {}, w) - elif name == "antigravity": - body = antigravity_tab(antigravity, w, h) - sub = antigravity_plan_rows(antigravity, w) - else: - body = elsewhere_tab(name, installed, w, h) - body = add_section(body, sub) - - hints = [[(ACCENT, "←→"), (DIM, " agent")], - [(ACCENT, "↑↓"), (DIM, " scroll")], - [(DIM, "[r]efresh")], [(DIM, "[q]uit")]] - # The footer is packed once, with the scroll hint always counted, so - # the body's height does not change when scrolling becomes possible. - # Sizing it against a shorter footer and then growing the footer would - # move the fold under the reader's cursor. - reserved = len(pack_hints(hints, w - 2)) - avail = max(1, h - len(rows) - reserved) - top = max(0, len(body) - avail) - off = min(offsets.get(name, 0), top) - for move in moves: - if move == "top": - off = 0 - elif move == "bottom": - off = top - elif move == "-page": - off -= max(1, avail - 1) - elif move == "+page": - off += max(1, avail - 1) - else: - off += move - off = max(0, min(off, top)) - offsets[name] = off - - view = body[off:off + avail] - if top: - # Never let a partial view read as the whole tab, and say which - # way there is more: an arrow that is simply absent at the top of - # a long tab looks the same as a tab that ends there. - rows[status_at] = status(" %d-%d of %d %s%s" - % (off + 1, off + len(view), len(body), - "▲" if off else " ", - "▼" if off < top else " ")) - else: - hints = [x for x in hints if x[0][1] != "↑↓"] - footer = [" " + line for line in pack_hints(hints, w - 2)] - # Padded back to the height already reserved, so dropping the scroll - # hint does not lift the footer off the bottom of the pane. - footer = [""] * (reserved - len(footer)) + footer - rows += view - while len(rows) < h - len(footer): - rows.append("") - rows.extend(footer) - draw(rows[:h], w, h) - time.sleep(0.3) - - -main() diff --git a/widgets/Cargo.toml b/widgets/Cargo.toml new file mode 100644 index 0000000..1f81da7 --- /dev/null +++ b/widgets/Cargo.toml @@ -0,0 +1,76 @@ +[package] +name = "toys-widgets" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +toys-core = { path = "../core" } +libc = "0.2" +serde_json = "1" +# clocks needs these; nothing else does yet, and an unused dependency costs +# nothing at run time because LTO drops what no binary reaches. +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +chrono-tz = "0.10" +# usage reads three of its six agent vendors out of SQLite files, which is +# what Cursor, Copilot and Antigravity each keep their history in. Python +# has sqlite3 in its standard library; this is the equivalent, bundled so +# that the binary still runs on a machine with no sqlite installed - the +# single executable being the point of the port. +rusqlite = { version = "0.32", features = ["bundled"] } + +[[bin]] +name = "ports" +path = "src/bin/ports.rs" + +[[bin]] +name = "netwatch" +path = "src/bin/netwatch.rs" + +[[bin]] +name = "latency" +path = "src/bin/latency.rs" + +[[bin]] +name = "matrix" +path = "src/bin/matrix.rs" + +[[bin]] +name = "link" +path = "src/bin/link.rs" + +[[bin]] +name = "clocks" +path = "src/bin/clocks.rs" + +[[bin]] +name = "herdr-panes" +path = "src/bin/herdr-panes.rs" + +[[bin]] +name = "deployments" +path = "src/bin/deployments.rs" + +[[bin]] +name = "start" +path = "src/bin/start.rs" + +[[bin]] +name = "linear" +path = "src/bin/linear.rs" + +[[bin]] +name = "pr" +path = "src/bin/pr.rs" + +[[bin]] +name = "github" +path = "src/bin/github.rs" + +[[bin]] +name = "tailnet" +path = "src/bin/tailnet.rs" + +[[bin]] +name = "usage" +path = "src/bin/usage.rs" diff --git a/widgets/src/bin/clocks.rs b/widgets/src/bin/clocks.rs new file mode 100644 index 0000000..47ebb32 --- /dev/null +++ b/widgets/src/bin/clocks.rs @@ -0,0 +1,1864 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Clocks: this server's, everyone else's, and the ones counting down. +//! +//! A port of clocks.py: the big clock, the countdown bars, the pomodoro, +//! and a world clock. The one widget here that needs a timezone database, +//! which is why the core carries one. + +use std::time::Duration; + +use chrono::{Datelike, Local, NaiveTime, Offset, TimeZone, Timelike, Utc}; +use chrono_tz::Tz; +use toys_core as tc; + +/// Five rows per digit, as clocks.py draws them. +const BIG: &[(char, [&str; 5])] = &[ + ('0', ["███", "█ █", "█ █", "█ █", "███"]), + ('1', [" █", " █", " █", " █", " █"]), + ('2', ["███", " █", "███", "█ ", "███"]), + ('3', ["███", " █", "███", " █", "███"]), + ('4', ["█ █", "█ █", "███", " █", " █"]), + ('5', ["███", "█ ", "███", " █", "███"]), + ('6', ["███", "█ ", "███", "█ █", "███"]), + ('7', ["███", " █", " █", " █", " █"]), + ('8', ["███", "█ █", "███", "█ █", "███"]), + ('9', ["███", "█ █", "███", " █", "███"]), + (':', [" ", " █ ", " ", " █ ", " "]), +]; + +fn glyph(c: char) -> [&'static str; 5] { + BIG.iter() + .find(|(ch, _)| *ch == c) + .map(|(_, rows)| *rows) + .unwrap_or([" ", " ", " ", " ", " "]) +} + +/// A time as five rows of blocks. +fn render_big(text: &str) -> Vec { + let mut rows = vec![String::new(); 5]; + for c in text.chars() { + let art = glyph(c); + for (i, row) in rows.iter_mut().enumerate() { + row.push_str(art[i]); + row.push(' '); + } + } + rows +} + +fn hms(seconds: i64) -> String { + let s = seconds.max(0); + format!("{:02}:{:02}:{:02}", s / 3600, (s % 3600) / 60, s % 60) +} + +/// The offset from UTC, as a person writes it. +fn offset_str(when: &chrono::DateTime) -> String +where + T: TimeZone, + T::Offset: Offset, +{ + let seconds = when.offset().fix().local_minus_utc(); + let sign = if seconds < 0 { '-' } else { '+' }; + let total = seconds.abs(); + let (hours, minutes) = (total / 3600, (total % 3600) / 60); + if minutes == 0 { + format!("UTC{}{}", sign, hours) + } else { + format!("UTC{}{}:{:02}", sign, hours, minutes) + } +} + +/// A progress bar of the given width, filled to `frac`. +fn bar(frac: f64, width: usize) -> String { + let filled = ((frac.clamp(0.0, 1.0)) * width as f64).round() as usize; + let mut out = "█".repeat(filled); + out.push_str(&"░".repeat(width.saturating_sub(filled))); + out +} + +struct Countdown { + label: String, + left: i64, + frac: f64, + /// Its own colour: the three are different clocks, not three readings + /// of one, and a single hue makes them look like a stack of the same + /// thing. + ink: String, +} + +/// Which days are working days, and between which hours. +#[derive(Clone)] +struct Office { + /// Monday is 0. Defaults to Monday through Friday. + days: Vec, + start: u32, + end: u32, +} + +impl Office { + fn from_config(cfg: &serde_json::Value) -> Office { + let days = cfg + .get("work_days") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| day_number(v)) + .filter(|d| *d < 7) + .collect::>() + }) + .filter(|d: &Vec| !d.is_empty()) + .unwrap_or_else(|| vec![0, 1, 2, 3, 4]); + Office { + days, + // Hours are 0..=23. An out-of-range value used to reach + // NaiveTime::from_hms_opt and unwrap, so a typo of 24 aborted + // the widget on the first frame instead of rendering. + start: (tc::cfg_usize(cfg, "work_start_hour", 9) as u32).min(23), + end: (tc::cfg_usize(cfg, "work_end_hour", 18) as u32).min(23), + } + } + + fn is_working_day(&self, when: &chrono::DateTime) -> bool { + self.days.contains(&when.weekday().num_days_from_monday()) + } + + fn is_open(&self, when: &chrono::DateTime) -> bool { + self.is_working_day(when) && (self.start..self.end).contains(&when.hour()) + } + + /// The next opening after `now`, skipping days that are not worked. + fn next_open(&self, now: &chrono::DateTime) -> chrono::DateTime { + let mut day = now.date_naive(); + for _ in 0..14 { + let candidate = day.and_time(NaiveTime::from_hms_opt(self.start, 0, 0).unwrap()); + let stamp = Local.from_local_datetime(&candidate).single(); + if let Some(stamp) = stamp { + if stamp > *now && self.is_working_day(&stamp) { + return stamp; + } + } + day += chrono::Duration::days(1); + } + *now + } + + /// The last closing before `now`, for measuring how far into the gap + /// we are - without it the bar has nothing to fill from. + fn prev_close(&self, now: &chrono::DateTime) -> chrono::DateTime { + let mut day = now.date_naive(); + for _ in 0..14 { + let candidate = day.and_time(NaiveTime::from_hms_opt(self.end, 0, 0).unwrap()); + if let Some(stamp) = Local.from_local_datetime(&candidate).single() { + if stamp <= *now && self.is_working_day(&stamp) { + return stamp; + } + } + day -= chrono::Duration::days(1); + } + *now + } +} + +/// A weekday from a config entry, as a number or a name. +fn day_number(value: &serde_json::Value) -> Option { + if let Some(n) = value.as_u64() { + return Some(n as u32); + } + let name = value.as_str()?.to_lowercase(); + ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] + .iter() + .position(|d| name.starts_with(d)) + .map(|i| i as u32) +} + +/// The three fixed countdowns: the hour, the working day, and midnight. +fn countdowns(now: chrono::DateTime, office: &Office) -> Vec { + let mut out = Vec::new(); + + let into_hour = now.minute() as i64 * 60 + now.second() as i64; + out.push(Countdown { + label: "Next Hour".into(), + left: 3600 - into_hour, + frac: into_hour as f64 / 3600.0, + ink: tc::rgb(90, 220, 255), + }); + + // Inside working hours this counts to the close; outside them, to the + // next opening - which on a Friday evening is Monday morning, not + // tomorrow. The bar fills from the last close, so the gap has a span. + let today = now.date_naive(); + let (label, target, from) = if office.is_open(&now) { + let start = Local + .from_local_datetime(&today.and_time(NaiveTime::from_hms_opt(office.start, 0, 0).unwrap())) + .single() + .unwrap_or(now); + let end = Local + .from_local_datetime(&today.and_time(NaiveTime::from_hms_opt(office.end, 0, 0).unwrap())) + .single() + .unwrap_or(now); + ("End of Office Hour", end, start) + } else { + ("Start of Office Hour", office.next_open(&now), office.prev_close(&now)) + }; + let span = (target - from).num_seconds().max(1); + let left = (target - now).num_seconds(); + out.push(Countdown { + label: label.into(), + left, + frac: 1.0 - (left as f64 / span as f64), + ink: tc::rgb(255, 200, 90), + }); + + let (into_day, day_len) = day_bounds(&now); + out.push(Countdown { + label: "End of Day".into(), + left: day_len - into_day, + frac: into_day as f64 / day_len as f64, + ink: tc::rgb(175, 130, 255), + }); + out +} + +/// How far into the local day `now` is, and how long that day runs. +/// +/// Measured between two midnights rather than assumed to be 86,400 +/// seconds: the day the clocks go forward is 23 hours long and the day +/// they go back is 25, and a fixed span puts both the time remaining and +/// the bar an hour out for the whole of a transition day. Generic over the +/// zone so the transition can be tested against a real one rather than +/// whichever zone this machine happens to be in. +fn day_bounds(now: &chrono::DateTime) -> (i64, i64) { + let zone = now.timezone(); + // Midnight itself is sometimes the hour that does not exist - Chile and + // Lebanon have moved their clocks at exactly that moment - so take the + // first minute of the day that does. + let midnight = |day: chrono::NaiveDate| { + (0..180).find_map(|m| { + zone.from_local_datetime( + &(day.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()) + + chrono::Duration::minutes(m)), + ) + .earliest() + }) + }; + let today = now.date_naive(); + match (midnight(today), midnight(today + chrono::Duration::days(1))) { + (Some(start), Some(end)) if end.timestamp() > start.timestamp() => ( + (now.timestamp() - start.timestamp()).max(0), + end.timestamp() - start.timestamp(), + ), + // A day the zone cannot place at all: fall back to the wall clock, + // which is what the day looks like on every day but the two it moves. + _ => (now.num_seconds_from_midnight() as i64, 86_400), + } +} + +/// Seconds each flash stays lit. +const FLASH_ON: f64 = 0.35; + +/// Is the panel lit right now? +/// +/// Flashes are derived from one timestamp rather than driven by sleeps, so +/// the render loop keeps running - the clock stays live and keys stay +/// responsive while it blinks. +fn flash_window(started: Option, count: u32, gap: f64, now: f64) -> bool { + let started = match started { + Some(s) => s, + None => return false, + }; + let since = now - started; + (0..count.max(1)).any(|n| { + let edge = n as f64 * gap; + since >= edge && since < edge + FLASH_ON + }) +} + +/// The same frame, repainted solid: colours stripped, one loud background. +fn flash_frame(rows: &[String], w: usize, h: usize, bg: &str, fg: &str) -> Vec { + (0..h) + .map(|i| { + let plain = rows.get(i).map(|r| strip_ansi(r)).unwrap_or_default(); + format!("{}{}{}", bg, fg, tc::pad(&plain, w)) + }) + .collect() +} + +fn strip_ansi(text: &str) -> String { + let mut out = String::new(); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' { + for n in chars.by_ref() { + if n == 'm' || n.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +/// A readable foreground for whatever the flash colour is. +/// +/// A near-white flash needs dark text on it and a dark one needs light; +/// picking by luminance means a configured colour cannot make the panel +/// unreadable at the moment it is trying to get your attention. +fn flash_ink(rgb: (u8, u8, u8)) -> String { + let lum = (0.299 * rgb.0 as f64 + 0.587 * rgb.1 as f64 + 0.114 * rgb.2 as f64) / 255.0; + if lum > 0.5 { + tc::rgb(18, 20, 26) + } else { + tc::rgb(255, 240, 240) + } +} + +/// Herdr, when we happen to be inside it, can raise a real toast. +/// +/// Purely additive: nothing here requires Herdr, and outside it this is a +/// no-op. The widget usually runs on a server, so anything local to that +/// machine - notify-send, a sound file - would fire where nobody is. +fn herdr_toast(title: &str, body: &str) { + if std::env::var("HERDR_ENV").unwrap_or_default() != "1" { + return; + } + let mut sent = SENT.lock().unwrap_or_else(|e| e.into_inner()); + reap(&mut sent); + if let Ok(child) = std::process::Command::new("herdr") + // --body, not a second positional: `herdr notification show` takes + // one and the body is an option. Passing it positionally + // fails with "unknown option" - silently, since stderr is nulled - + // so this toast had never once been shown. + .args(["notification", "show", title, "--body", body, "--sound", "done"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + sent.push(child); + } +} + +/// Toasts already sent, still holding an exit status nobody has read. +/// +/// A `Child` that is never waited on stays a zombie for as long as the +/// widget runs, and an ignored pomodoro toasts once a minute - a panel +/// left alone for a day would leave hundreds of them behind. Each toast +/// clears the ones before it, which needs no thread of its own. +static SENT: std::sync::Mutex<Vec<std::process::Child>> = std::sync::Mutex::new(Vec::new()); + +/// Drop the handle of every toast that has finished. +/// +/// `try_wait` does not block, so a `herdr` that hangs keeps its slot in +/// the list rather than holding up the render loop. A handle that cannot +/// be waited on at all is dropped too: keeping it reaps nothing. +fn reap(sent: &mut Vec<std::process::Child>) { + sent.retain_mut(|child| matches!(child.try_wait(), Ok(None))); +} + +/// The pomodoro, and the state it keeps between runs. +/// +/// Hidden means suspended, not merely invisible. A timer that keeps +/// counting while out of sight is worse than no timer: you come back to a +/// focus block that expired half an hour ago. Hiding freezes it where it +/// stands and showing resumes it only if it was running when it went away. +struct Pomodoro { + phase: Phase, + running: bool, + /// pomodoro_enabled, then whatever the state file remembers: whether + /// the timer is on screen at all. It is the visibility flag in + /// clocks.py - and stored under the same "enabled" key - not a flag + /// for whether it runs, which no setting starts. + shown: bool, + /// When the current phase ends, while running. + deadline: f64, + /// What was left when it stopped, while not. + left: f64, + done: u32, + focus: f64, + short: f64, + long: f64, + before_long: u32, + bell: bool, + rang_at: i64, + /// The day the tally belongs to, as %Y-%m-%d. Kept so a panel left + /// running over midnight zeroes rather than adding to yesterday. + day: String, + /// show_hints: whether the pomodoro's key hints are under the panel. + /// A display preference rather than timer state, but it rides in the + /// same file - under "hints" - so the panel comes back looking how it + /// was left. + hints: bool, + /// pomodoro_notify: announce a phase change to the terminal and to + /// Herdr. clocks.py reads this and the port did not, so setting it did + /// nothing here. + notify: bool, +} + +/// Where the pomodoro's state lives, shared with clocks.py. +/// +/// The same path and the same keys, so a session started under one +/// implementation is picked up by the other rather than each keeping a +/// private tally of the same afternoon. +fn state_file() -> String { + let base = std::env::var("XDG_STATE_HOME").unwrap_or_else(|_| { + format!( + "{}/.local/state", + std::env::var("HOME").unwrap_or_default() + ) + }); + format!("{}/terminal-toys/pomodoro.json", base) +} + +/// Today, in the form the state file stores. +fn today() -> String { + Local::now().format("%Y-%m-%d").to_string() +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Phase { + Focus, + Short, + Long, +} + +impl Phase { + fn label(self) -> &'static str { + match self { + Phase::Focus => "FOCUS", + Phase::Short => "SHORT BREAK", + Phase::Long => "LONG BREAK", + } + } +} + +impl Pomodoro { + fn new(cfg: &serde_json::Value) -> Pomodoro { + let focus = tc::cfg_f64(cfg, "pomodoro_focus_minutes", 25.0); + let mut it = Pomodoro { + phase: Phase::Focus, + running: false, + // Off until [p], which is what clocks.py does and what the doc + // promises: pomodoro_enabled is its visibility flag and it + // defaults to false. Hardcoding this true put an optional + // panel on screen for everyone who had turned it off. + shown: cfg + .get("pomodoro_enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + deadline: 0.0, + left: focus * 60.0, + done: 0, + focus, + short: tc::cfg_f64(cfg, "pomodoro_short_break_minutes", 5.0), + long: tc::cfg_f64(cfg, "pomodoro_long_break_minutes", 15.0), + before_long: tc::cfg_usize(cfg, "pomodoro_sessions_before_long_break", 4) as u32, + bell: cfg + .get("pomodoro_bell") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + rang_at: -1, + day: today(), + // Visible by default, as clocks.py has it: the hints are how + // the keys are found in the first place, and starting hidden + // means a reader has to already know the key that reveals them. + hints: cfg + .get("show_hints") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + // True, as clocks.py has it: a break ending is worth saying + // out loud, and a machine with no config should behave the + // same under either implementation. + notify: cfg + .get("pomodoro_notify") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + }; + it.left = it.duration(); + // Never running from config alone. Setting it running here left the + // deadline at zero, and a deadline of zero falls back to a `left` + // that nothing decrements: a timer frozen at 25:00 with no "paused" + // beside it. Only a saved deadline resumes a block, in load(). + it.load(); + it + } + + /// Read the state file, if there is one. + /// + /// Preferences outlive the day; only the tally and the block in + /// progress belong to it, so a file from yesterday contributes the + /// focus length and nothing else. + fn load(&mut self) { + let Ok(text) = std::fs::read_to_string(state_file()) else { + return; + }; + let Ok(d) = serde_json::from_str::<serde_json::Value>(&text) else { + return; + }; + if let Some(v) = d.get("focus").and_then(|v| v.as_f64()) { + self.focus = v; + } + // "enabled" is visibility and "hints" is the hint preference - the + // port had the second one holding the first, so hiding the panel + // rewrote whether the keys are listed and hiding the keys moved + // the panel. + if let Some(v) = d.get("enabled").and_then(|v| v.as_bool()) { + self.shown = v; + } + if let Some(v) = d.get("hints").and_then(|v| v.as_bool()) { + self.hints = v; + } + if d.get("day").and_then(|v| v.as_str()) != Some(self.day.as_str()) { + return; // a new day starts a fresh count + } + if let Some(v) = d.get("phase").and_then(|v| v.as_str()) { + self.phase = match v { + "short" => Phase::Short, + "long" => Phase::Long, + _ => Phase::Focus, + }; + } + self.done = d.get("completed").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + if let Some(v) = d.get("left").and_then(|v| v.as_f64()) { + self.left = v; + } + // Resume mid-phase. If it elapsed while the panel was away the + // timer simply shows how far over it has run, which is what the + // Python does rather than pretending it ended on time. + let deadline = d.get("deadline").and_then(|v| v.as_f64()).unwrap_or(0.0); + if d.get("running").and_then(|v| v.as_bool()).unwrap_or(false) && deadline > 0.0 { + self.deadline = deadline; + self.running = true; + } + } + + /// Write the state file, failing quietly. + /// + /// A panel that cannot save its tally should still show the clock. + fn save(&self) { + let path = state_file(); + if let Some(dir) = std::path::Path::new(&path).parent() { + let _ = std::fs::create_dir_all(dir); + } + let body = serde_json::json!({ + "day": self.day, + "phase": match self.phase { + Phase::Focus => "focus", + Phase::Short => "short", + Phase::Long => "long", + }, + "completed": self.done, + "focus": self.focus, + "enabled": self.shown, + "running": self.running, + "was_running": self.running, + "hints": self.hints, + "left": self.left, + "deadline": self.deadline, + }); + let _ = std::fs::write(&path, body.to_string()); + } + + /// Zero the tally when the date changes, even if nothing restarted. + /// + /// clocks.py's own docstring names this: the count previously reset + /// only on load, so a panel left running over midnight kept adding to + /// yesterday's total. The port had reintroduced exactly that. + fn roll_day(&mut self) -> bool { + let now = today(); + if now != self.day { + self.day = now; + self.done = 0; + self.save(); + return true; + } + false + } + + fn duration(&self) -> f64 { + 60.0 * match self.phase { + Phase::Focus => self.focus, + Phase::Short => self.short, + Phase::Long => self.long, + } + } + + /// Seconds left, negative once the phase has been overrun. + fn signed(&self, now: f64) -> f64 { + if self.running && self.deadline > 0.0 { + self.deadline - now + } else { + self.left + } + } + + fn remaining(&self, now: f64) -> f64 { + self.signed(now).max(0.0) + } + + fn overtime(&self, now: f64) -> f64 { + (-self.signed(now)).max(0.0) + } + + /// Show or hide, suspending with it. + fn toggle(&mut self, now: f64) { + if self.shown { + self.left = self.signed(now); + self.shown = false; + } else { + self.shown = true; + if self.running { + self.deadline = now + self.left; + } + } + self.save(); + } + + fn start_stop(&mut self, now: f64) { + if self.running { + self.left = self.signed(now); + self.running = false; + } else { + self.running = true; + self.deadline = now + self.left; + } + self.save(); + } + + /// Move to whatever comes next, counting a finished focus block. + fn advance(&mut self, now: f64) { + if self.phase == Phase::Focus { + self.done += 1; + self.phase = if self.before_long > 0 && self.done % self.before_long == 0 { + Phase::Long + } else { + Phase::Short + }; + } else { + self.phase = Phase::Focus; + } + self.left = self.duration(); + self.deadline = now + self.left; + self.rang_at = -1; + self.save(); + } + + /// What pressing the break key will do, right now. + /// + /// One key for both directions, and the label says which it is about to + /// do rather than a fixed word like "skip" that describes neither. The + /// long break is called out because it is the one worth waiting for. + fn next_label(&self) -> String { + if self.phase != Phase::Focus { + return "[b]reak stop".into(); + } + let long_next = self.before_long > 0 && (self.done + 1) % self.before_long == 0; + if long_next { + "[b]reak start (long)".into() + } else { + "[b]reak start".into() + } + } + + /// Zero the tally, once there is something to zero. + fn reset_count(&mut self) { + self.done = 0; + self.save(); + } + + /// Lengthen or shorten the block you are actually in, in minutes. + /// + /// Whichever phase is running: focus during focus, and that break during + /// a break. It used to write to `focus` whatever the phase, so pressing + /// it during a break moved a number that was not on screen and left the + /// countdown alone - and the hint beside it read "focus" while the line + /// above it read BREAK. Both breaks keep their own length, so shortening + /// a short break does not shorten the long one. + /// + /// It takes no clock: moving the finish line needs the size of the + /// change, not the time of it. + fn adjust(&mut self, delta: f64) { + let before = self.duration(); + let slot = match self.phase { + Phase::Focus => &mut self.focus, + Phase::Short => &mut self.short, + Phase::Long => &mut self.long, + }; + *slot = (*slot + delta).clamp(1.0, 180.0); + // Move the finish line by however much the block actually changed - + // the clamp can make that less than was asked for - rather than + // starting the block again. A minute added twenty minutes into a + // twenty-five minute block leaves six, not twenty-six. Shortening + // below what has already gone leaves the block over, which is + // true, and the counter says so by climbing. + let moved = self.duration() - before; + self.left += moved; + if self.running { + self.deadline += moved; + } + self.save(); + } + + /// The length of the block in progress, in minutes. + fn minutes(&self) -> i64 { + (self.duration() / 60.0).round() as i64 + } + + fn restart(&mut self, now: f64) { + self.left = self.duration(); + self.deadline = now + self.left; + self.rang_at = -1; + self.save(); + } + + /// One tick: ring on elapse, and once a minute while overrunning. + /// + /// It does not advance on its own. A break that starts itself while you + /// are mid-sentence is a break you ignore, and then the count is a lie. + /// Returns true on the tick an alert fires, so the panel can flash. + fn tick(&mut self, now: f64) -> bool { + if !self.running || !self.shown { + return false; + } + let over = self.overtime(now); + if over <= 0.0 { + return false; + } + let minute = (over / 60.0) as i64; + if minute == self.rang_at { + return false; + } + self.rang_at = minute; + // Every channel gets the same sentence, and how far over is part of + // it: the render loop used to send a second, richer toast of its + // own, which meant two notifications under Herdr and one even with + // pomodoro_notify off. + self.alert(&format!( + "{} elapsed{}", + self.phase.label(), + if over >= 60.0 { + format!(", {} over", hms(over as i64)) + } else { + String::new() + } + )); + true + } + + /// Nudge whoever is in front of the terminal, over SSH if need be. + /// + /// BEL is universal. OSC 9 covers iTerm2, WezTerm, Windows Terminal + /// and Ghostty; OSC 777 covers urxvt and several others. Terminals + /// ignore the sequences they do not implement, so sending both costs + /// nothing, and a multiplexer in between decides whether to forward + /// them. + fn alert(&self, text: &str) { + if self.bell { + tc::out("\x07"); + } + if self.notify { + tc::out(&format!("\x1b]9;{}\x07", text)); + tc::out(&format!("\x1b]777;notify;Pomodoro;{}\x07", text)); + } + tc::flush(); + if self.notify { + herdr_toast("Pomodoro", text); + } + } +} + + +struct City { + name: String, + zone: Tz, +} + +fn main() { + tc::maybe_help(include_str!("clocks_help.txt")); + let cfg = tc::load_config("clocks"); + let office = Office::from_config(&cfg); + let cities = load_cities(&cfg); + + let p = palette(); + let mut pomo = Pomodoro::new(&cfg); + let flash_rgb = cfg + .get("pomodoro_flash_rgb") + .and_then(|v| v.as_array()) + .map(|a| { + let n = |i: usize| a.get(i).and_then(|v| v.as_u64()).unwrap_or(250) as u8; + (n(0), n(1), n(2)) + }) + .unwrap_or((246, 248, 252)); + let flash_bg = tc::bg(flash_rgb.0, flash_rgb.1, flash_rgb.2); + let flash_fg = flash_ink(flash_rgb); + let flash_count = tc::cfg_usize(&cfg, "pomodoro_flash_count", 2) as u32; + let flash_gap = tc::cfg_f64(&cfg, "pomodoro_flash_gap", 1.0); + let flash_on = cfg + .get("pomodoro_flash") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let mut flash_started: Option<f64> = None; + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let mut scroll = 0usize; + + loop { + for key in keyboard.poll() { + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "up" | "k" | "K" => scroll = scroll.saturating_sub(1), + "down" | "j" | "J" => scroll += 1, + "pgup" => scroll = scroll.saturating_sub(8), + "pgdn" => scroll += 8, + "home" => scroll = 0, + // Clamped to the last city further down, which is the only + // place that knows how many there are. + "end" => scroll = usize::MAX / 2, + "p" | "P" => pomo.toggle(seconds()), + // show_hints decides where this starts and [?] moves it, + // but the answer outlives the session: it rides in the + // pomodoro's state file, so hiding the hints once hides + // them tomorrow too. It used to live in a local nothing + // wrote down. + "?" | "h" => { + pomo.hints = !pomo.hints; + pomo.save(); + } + // Everything below moves a timer that is not running, so + // it is ignored rather than silently acted on. + _ if !pomo.shown => {} + " " => pomo.start_stop(seconds()), + // One action, three mnemonics: skip / break / end. + "s" | "S" | "b" | "B" | "e" | "E" => pomo.advance(seconds()), + "r" | "R" => pomo.restart(seconds()), + "0" | "c" => pomo.reset_count(), + "+" | "=" => pomo.adjust(1.0), + "-" | "_" => pomo.adjust(-1.0), + _ => {} + } + } + + let (w, h) = tc::size(); + let now = Local::now(); + let mut rows = vec![tc::title("clocks", w, &p.head)]; + rows.push(tc::seg(&[(p.lbl.as_str(), " ── SERVER TIME ──".into())], w - 1)); + + // Two colours down the digits, not one: the top three rows are + // bright and the base is darker, which is what gives them weight. + for (i, line) in render_big(&now.format("%H:%M:%S").to_string()) + .into_iter() + .enumerate() + { + let ink = if i < 3 { &p.big_top } else { &p.big_base }; + rows.push(tc::seg(&[(ink.as_str(), format!(" {}", line))], w - 1)); + } + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {}", now.format("%Y-%m-%d"))), + ( + p.dim.as_str(), + format!(" {} {}", now.format("%A").to_string().to_uppercase(), offset_str(&now)), + ), + ], + w - 1, + )); + rows.push(String::new()); + + rows.push(tc::seg(&[(p.lbl.as_str(), " ── COUNTDOWN ──".into())], w - 1)); + let bar_w = w.saturating_sub(3).min(90); + + // The pomodoro leads the section, as it does in the Python. + let stamp = seconds(); + // Before anything reads the tally: a panel left running over + // midnight must zero rather than keep adding to yesterday. + pomo.roll_day(); + if pomo.tick(stamp) { + // The flash only. tick() has already alerted on every channel + // the settings allow, the toast among them. + flash_started = Some(stamp); + } + if pomo.shown { + let over = pomo.overtime(stamp); + let left = pomo.remaining(stamp); + let frac = 1.0 - (left / pomo.duration().max(1.0)); + // Paused takes its own ink: a stopped timer showing focus red + // reads as one that is running. + let ink = if !pomo.running { + &p.paused + } else if pomo.phase == Phase::Focus { + &p.focus + } else { + &p.rest + }; + rows.push(tc::seg( + &[ + ( + ink.as_str(), + format!(" {}", tc::pad(&format!("Pomodoro · {}", pomo.phase.label()), 23)), + ), + ( + if over > 0.0 { &p.over } else { &p.txt }, + if over > 0.0 { + format!("+{}", hms(over as i64)) + } else { + hms(left as i64) + }, + ), + ( + p.paused.as_str(), + if pomo.running { String::new() } else { " paused".into() }, + ), + ( + if over > 0.0 { &p.over } else { &p.dim }, + if over > 0.0 { " OVER".into() } else { String::new() }, + ), + (p.dim.as_str(), format!(" {} done", pomo.done)), + ], + w - 1, + )); + if over > 0.0 { + // The bar rescales to duration plus overtime, so the red + // share grows the longer the phase is ignored - the point + // being that it keeps getting harder to miss. + let total = pomo.duration() + over; + let base = ((bar_w as f64 * pomo.duration() / total).round() as usize).max(1); + rows.push(tc::seg( + &[ + (ink.as_str(), format!(" {}", "█".repeat(base))), + (p.over.as_str(), "█".repeat(bar_w.saturating_sub(base))), + ], + w - 1, + )); + } else { + rows.push(tc::seg( + &[(ink.as_str(), format!(" {}", bar(frac, bar_w)))], + w - 1, + )); + } + } + for item in countdowns(now, &office) { + rows.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {:<21}", item.label)), + (p.accent.as_str(), hms(item.left)), + ], + w - 1, + )); + rows.push(tc::seg( + &[(item.ink.as_str(), format!(" {}", bar(item.frac, bar_w)))], + w - 1, + )); + } + rows.push(String::new()); + + // The world clock takes whatever is left, and says which slice of + // the list it is showing rather than silently truncating. + let room = h.saturating_sub(rows.len() + 3); + if room >= 2 && !cities.is_empty() { + let shown = room.saturating_sub(1).min(cities.len()); + if scroll + shown > cities.len() { + scroll = cities.len().saturating_sub(shown); + } + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── WORLD CLOCK ── ".into()), + ( + p.dim.as_str(), + format!(" {}-{} of {} ↑↓", scroll + 1, scroll + shown, cities.len()), + ), + ], + w - 1, + )); + for city in cities.iter().skip(scroll).take(shown) { + let there = now.with_timezone(&city.zone); + // Sun or moon by the local hour, which is the fastest way + // to read "is it a reasonable time to message them". + let (ink, glyph) = phase_of(&there, &p); + let day_shift = there.date_naive().signed_duration_since(now.date_naive()).num_days(); + rows.push(tc::seg( + &[ + (ink.as_str(), format!(" {} ", glyph)), + (p.txt.as_str(), tc::pad(&city.name, 16)), + (p.txt.as_str(), there.format("%H:%M").to_string()), + ( + p.dim.as_str(), + format!(" {} {}", there.format("%a"), offset_str(&there)), + ), + ( + p.dim.as_str(), + match day_shift { + 0 => String::new(), + d if d > 0 => format!(" +{}d", d), + d => format!(" {}d", d), + }, + ), + ], + w - 1, + )); + } + } + + // Navigation first, as every other widget in the collection has it, + // then the pomodoro's own controls, then the panel keys. Only the + // pomodoro controls hide behind ?: hiding the way back would leave + // no way back. + let mut hints: Vec<Vec<(&str, String)>> = vec![vec![ + (p.accent.as_str(), "↑↓".into()), + (p.dim.as_str(), " cities".into()), + ]]; + if pomo.shown && pomo.hints { + hints.push(vec![ + (p.dim.as_str(), "[space] ".into()), + ( + p.txt.as_str(), + if pomo.running { "pause".into() } else { "start".into() }, + ), + ]); + hints.push(vec![(p.dim.as_str(), pomo.next_label())]); + hints.push(vec![(p.dim.as_str(), "[r]estart".into())]); + // Both halves, because each answers a question the other does + // not. The step says what the key will do; the value is the + // only place the block length appears while the timer runs, + // since the countdown shows what is left rather than what it + // started from. + hints.push(vec![ + (p.dim.as_str(), "[±]1min ".to_string()), + // "interval" rather than "focus": the key adjusts whichever + // block is running, so naming one of the three would be + // wrong in two phases out of three. + (p.txt.as_str(), format!("(interval {}min)", pomo.minutes())), + ]); + if pomo.done > 0 { + // Nothing to reset at zero, so it only appears once it counts. + hints.push(vec![( + p.dim.as_str(), + format!("[0]reset {} done", pomo.done), + )]); + } + } + hints.push(vec![( + p.dim.as_str(), + if pomo.shown { + "[p]omodoro off".into() + } else { + "[p]omodoro".into() + }, + )]); + if pomo.shown { + // Shown even when the controls are hidden, so there is always a + // way back. Names the action rather than the state. + hints.push(vec![( + p.dim.as_str(), + format!( + "[?]{} pomodoro tips", + if pomo.hints { "hide" } else { "show" } + ), + )]); + } + hints.push(vec![(p.dim.as_str(), "[q]uit".into())]); + let foot: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + while rows.len() < h.saturating_sub(foot.len()) { + rows.push(String::new()); + } + rows.extend(foot); + if flash_on && flash_window(flash_started, flash_count, flash_gap, seconds()) { + tc::draw(&flash_frame(&rows, w, h, &flash_bg, &flash_fg), w, h); + } else { + tc::draw(&rows, w, h); + } + // Forget a flash once its last blink has passed, so the check stops + // costing anything for the rest of the session. + if let Some(started) = flash_started { + if seconds() - started > flash_count as f64 * flash_gap + FLASH_ON { + flash_started = None; + } + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +/// Colour and glyph for what people there are plausibly doing. +/// +/// Four states rather than two: asleep, weekend, working, and the evening +/// either side of it. The glyph says light or dark and the colour says +/// whether anyone is likely to be at a desk, which is the actual question +/// being asked of a world clock. +fn phase_of(there: &chrono::DateTime<Tz>, p: &Palette) -> (String, &'static str) { + let hour = there.hour() as f64 + there.minute() as f64 / 60.0; + let weekend = there.weekday().num_days_from_monday() >= 5; + if hour < 6.5 || hour >= 22.0 { + return (p.night.clone(), "☾"); + } + if weekend { + return (p.weekend.clone(), "☀"); + } + if (9.0..18.0).contains(&hour) { + return (p.work.clone(), "☀"); + } + if hour >= 18.0 { + return (p.eve.clone(), "☾"); + } + (p.eve.clone(), "☀") +} + +/// The configured cities, or the four the Python ships with. +/// +/// A zone the database does not know is dropped rather than defaulted to +/// UTC: a clock quietly showing the wrong city is worse than one absent. +fn load_cities(cfg: &serde_json::Value) -> Vec<City> { + let mut out = Vec::new(); + // Whether the key is there at all, which is a different thing from it + // being empty. Absent means nobody has said what they want and a default + // is a kindness; present means they have, and substituting four cities + // of our own for the answer they gave is the widget telling them about + // somewhere they did not ask about. + let asked = cfg.get("cities").and_then(|v| v.as_array()).is_some(); + if let Some(items) = cfg.get("cities").and_then(|v| v.as_array()) { + for pair in items { + let name = pair.get(0).and_then(|v| v.as_str()).unwrap_or(""); + let zone = pair.get(1).and_then(|v| v.as_str()).unwrap_or(""); + if let Ok(tz) = zone.parse::<Tz>() { + out.push(City { + name: name.to_string(), + zone: tz, + }); + } + } + } + if !out.is_empty() { + // West to east by current offset, so the row order is a map. + let now = Utc::now(); + out.sort_by_key(|c| { + ( + now.with_timezone(&c.zone).offset().fix().local_minus_utc(), + c.name.clone(), + ) + }); + return out; + } + // Only when nothing was configured. A list that was set and parsed to + // nothing - empty, or every zone a typo - is left empty, so the row that + // is missing is the question rather than a plausible answer to a + // different one. + if out.is_empty() && !asked { + for (name, zone) in [ + ("San Francisco", "America/Los_Angeles"), + ("London", "Europe/London"), + ("Singapore", "Asia/Singapore"), + ("Tokyo", "Asia/Tokyo"), + ] { + if let Ok(tz) = zone.parse::<Tz>() { + out.push(City { + name: name.into(), + zone: tz, + }); + } + } + } + out +} + +fn seconds() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +struct Palette { + focus: String, + rest: String, + paused: String, + over: String, + work: String, + eve: String, + night: String, + weekend: String, + dim: String, + txt: String, + lbl: String, + accent: String, + head: String, + big_top: String, + big_base: String, + // Nothing draws these three. They are clocks.py's palette carried + // over whole, and a palette is a set: taking the unused thirds out + // would leave the rest looking chosen rather than inherited, and the + // file they were inherited from is now deleted, so there would be + // nothing left to check them against. + #[allow(dead_code)] + bar: String, + #[allow(dead_code)] + sun: String, + #[allow(dead_code)] + moon: String, +} + +fn palette() -> Palette { + // clocks.py's own palette, value for value. Its DIM is a green-grey + // rather than the blue-grey the other widgets use, and the difference + // is visible the moment the two sit side by side. + Palette { + focus: tc::rgb(255, 130, 120), + rest: tc::rgb(120, 235, 170), + paused: tc::rgb(160, 172, 190), + over: tc::rgb(255, 80, 90), + work: tc::rgb(130, 255, 180), + eve: tc::rgb(255, 200, 90), + night: tc::rgb(95, 130, 175), + weekend: tc::rgb(150, 150, 170), + dim: tc::rgb(70, 130, 110), + txt: tc::rgb(220, 255, 240), + lbl: tc::rgb(70, 130, 110), + accent: tc::rgb(90, 220, 255), + head: tc::rgb(0, 255, 170), + big_top: tc::rgb(120, 255, 200), + big_base: tc::rgb(40, 150, 120), + bar: tc::rgb(90, 220, 255), + sun: tc::rgb(255, 210, 120), + moon: tc::rgb(150, 170, 210), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One lock for every test that touches the state file. + /// + /// XDG_STATE_HOME is process-global and cargo runs tests in parallel + /// threads, so without this one test's sandbox becomes another's - and + /// since Pomodoro::new loads, a test wanting a fresh 25-minute focus + /// would read whatever a concurrent test had just written. + static STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Take the lock and point the state file at an empty directory. + /// + /// The real file holds a running pomodoro that clocks.py shares, so a + /// careless test zeroes an actual afternoon. + fn sandbox(name: &str) -> std::sync::MutexGuard<'static, ()> { + let held = STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = format!("/tmp/toys-clocks-test-{}-{}", std::process::id(), name); + let _ = std::fs::remove_dir_all(&dir); + std::env::set_var("XDG_STATE_HOME", &dir); + held + } + + #[test] + fn a_panel_left_running_over_midnight_starts_a_fresh_count() { + // clocks.py's roll_day docstring names this as a bug it already + // fixed: the count used to reset only on load, so a panel running + // overnight kept adding to yesterday's total. The port had + // reintroduced it - there was no roll at all. + let _held = sandbox("roll"); + let mut p = Pomodoro::new(&serde_json::json!({})); + p.done = 5; + p.day = "2020-01-01".into(); + assert!(p.roll_day(), "a changed date should roll"); + assert_eq!(p.done, 0, "yesterday's tally carried into today"); + assert_eq!(p.day, today()); + // And the same day twice does nothing. + p.done = 2; + assert!(!p.roll_day()); + assert_eq!(p.done, 2, "the tally was zeroed on an unchanged day"); + } + + #[test] + fn the_tally_survives_a_restart_and_the_file_is_the_pythons() { + // The help text promises the pomodoro persists across restarts, + // and the port wrote nothing at all. The file is clocks.py's, key + // for key, so a session started under one is picked up by the + // other rather than each keeping a private tally. + let _held = sandbox("save"); + let mut p = Pomodoro::new(&serde_json::json!({})); + p.done = 3; + p.phase = Phase::Short; + p.save(); + + let text = std::fs::read_to_string(state_file()).expect("a state file"); + let d: serde_json::Value = serde_json::from_str(&text).expect("json"); + for key in [ + "day", "phase", "completed", "focus", "enabled", "running", + "was_running", "hints", "left", "deadline", + ] { + assert!(d.get(key).is_some(), "clocks.py reads {} and we omit it", key); + } + assert_eq!(d["completed"], 3); + assert_eq!(d["phase"], "short"); + + let back = Pomodoro::new(&serde_json::json!({})); + assert_eq!(back.done, 3, "the tally did not survive"); + assert_eq!(back.phase, Phase::Short); + } + + #[test] + fn a_tally_from_another_day_is_not_carried_forward() { + // Preferences outlive the day; the count does not. + let _held = sandbox("stale"); + let mut p = Pomodoro::new(&serde_json::json!({})); + p.done = 9; + p.day = "2020-01-01".into(); + p.save(); + let back = Pomodoro::new(&serde_json::json!({})); + assert_eq!(back.done, 0, "yesterday's count was loaded as today's"); + } + + #[test] + fn each_block_keeps_its_own_length() { + // The config numbers are starting values, not fixed ones: ± moves + // whichever block is running and leaves the other two alone, so a + // 25/5/15 day can become 30/5/15 without touching the breaks. + // adjust() saves, and the real state file is a running pomodoro + // that clocks.py shares - send the writes somewhere disposable. + let _held = sandbox("adjust"); + let mut pomo = Pomodoro { + phase: Phase::Focus, + running: false, + shown: true, + deadline: 0.0, + left: 25.0 * 60.0, + done: 0, + focus: 25.0, + short: 5.0, + long: 15.0, + before_long: 4, + bell: false, + rang_at: 0, + day: today(), + hints: true, + notify: false, + }; + + pomo.phase = Phase::Focus; + pomo.adjust(5.0); + assert_eq!((pomo.focus, pomo.short, pomo.long), (30.0, 5.0, 15.0)); + + pomo.phase = Phase::Short; + pomo.adjust(-2.0); + assert_eq!((pomo.focus, pomo.short, pomo.long), (30.0, 3.0, 15.0)); + + pomo.phase = Phase::Long; + pomo.adjust(1.0); + assert_eq!((pomo.focus, pomo.short, pomo.long), (30.0, 3.0, 16.0)); + + // And the hint reports the block in progress, not one named block. + assert_eq!(pomo.minutes(), 16); + pomo.phase = Phase::Short; + assert_eq!(pomo.minutes(), 3); + + // A block cannot be argued below a minute or above three hours. + pomo.phase = Phase::Short; + for _ in 0..10 { + pomo.adjust(-1.0); + } + assert_eq!(pomo.short, 1.0); + assert_eq!((pomo.focus, pomo.long), (30.0, 16.0), "the others are untouched"); + } + + #[test] + fn the_advance_key_says_what_it_will_do() { + let _held = sandbox("the_advance_key_says_what_it_will_do"); + let mut pomo = Pomodoro::new(&serde_json::json!({})); + // Three blocks done, so the fourth leads to the long break and the + // hint has to say so rather than promising an ordinary one. + assert_eq!(pomo.next_label(), "[b]reak start"); + pomo.done = 3; + assert_eq!(pomo.next_label(), "[b]reak start (long)"); + pomo.phase = Phase::Short; + assert_eq!(pomo.next_label(), "[b]reak stop"); + } + + #[test] + fn the_focus_length_can_be_nudged_and_stays_sane() { + // new() loads and adjust() saves, so this needs its own state. + let _held = sandbox("nudge"); + let mut pomo = Pomodoro::new(&serde_json::json!({})); + pomo.adjust(5.0); + assert_eq!(pomo.focus, 30.0); + assert_eq!(pomo.duration(), 30.0 * 60.0); + // It cannot be driven to zero or beyond a working day. + for _ in 0..100 { + pomo.adjust(-10.0); + } + assert!(pomo.focus >= 1.0, "focus fell to {}", pomo.focus); + } + + #[test] + fn the_tally_resets_only_when_there_is_one() { + let _held = sandbox("the_tally_resets_only_when_there_is_one"); + let mut pomo = Pomodoro::new(&serde_json::json!({})); + pomo.done = 4; + pomo.reset_count(); + assert_eq!(pomo.done, 0); + } + + #[test] + fn the_office_countdown_across_a_whole_week() { + let office = Office::from_config(&serde_json::json!({})); + // 2026-08-17 is a Monday, so +n days walks the week. + let at = |day: u32, hour: u32| { + Local.with_ymd_and_hms(2026, 8, day, hour, 0, 0).unwrap() + }; + let read = |when| { + let items = countdowns(when, &office); + (items[1].label.clone(), items[1].left) + }; + + // Inside hours on a working day: counting to the close. + let (label, left) = read(at(18, 11)); // Tuesday 11:00 + assert_eq!(label, "End of Office Hour"); + assert_eq!(left, 7 * 3600, "seven hours until six"); + + // Just before opening: counting to it, an hour off. + let (label, left) = read(at(18, 8)); + assert_eq!(label, "Start of Office Hour"); + assert_eq!(left, 3600); + + // After the close on a weekday: tomorrow morning. + let (label, left) = read(at(18, 19)); + assert_eq!(label, "Start of Office Hour"); + assert_eq!(left, 14 * 3600); + + // The last minute of the working day is still inside it. + let (label, _) = read(at(18, 17)); + assert_eq!(label, "End of Office Hour"); + + // Friday evening, Saturday, Sunday: all pointing at Monday. + for (day, hour) in [(21, 19), (22, 11), (23, 11)] { + let (label, left) = read(at(day, hour)); + assert_eq!(label, "Start of Office Hour", "on day {}", day); + let opens = office.next_open(&at(day, hour)); + assert_eq!(opens.weekday(), chrono::Weekday::Mon, "on day {}", day); + assert!(left > 0); + } + } + + #[test] + fn friday_evening_counts_to_monday_not_saturday() { + let office = Office::from_config(&serde_json::json!({})); + // Friday 21 August 2026 at 19:00 - after the close, and the next + // working day is three days off. + let friday = Local.with_ymd_and_hms(2026, 8, 21, 19, 0, 0).unwrap(); + assert!(!office.is_open(&friday)); + let opens = office.next_open(&friday); + assert_eq!(opens.weekday(), chrono::Weekday::Mon); + assert_eq!(opens.hour(), 9); + // Which is nearly three days, not the fourteen hours a naive + // "tomorrow at nine" would give. + let items = countdowns(friday, &office); + assert!(items[1].left > 48 * 3600, "got {}s", items[1].left); + } + + #[test] + fn a_saturday_is_not_a_working_day() { + let office = Office::from_config(&serde_json::json!({})); + let saturday = Local.with_ymd_and_hms(2026, 8, 22, 11, 0, 0).unwrap(); + assert!(!office.is_open(&saturday), "eleven on a Saturday is not office hours"); + assert_eq!(countdowns(saturday, &office)[1].label, "Start of Office Hour"); + } + + #[test] + fn the_working_week_can_be_configured() { + // A Sunday-to-Thursday week, as much of the Gulf works. + let office = Office::from_config(&serde_json::json!({ + "work_days": ["sun", "mon", "tue", "wed", "thu"], + "work_start_hour": 8, + "work_end_hour": 16 + })); + let sunday = Local.with_ymd_and_hms(2026, 8, 23, 9, 0, 0).unwrap(); + assert!(office.is_open(&sunday), "Sunday is a working day there"); + let friday = Local.with_ymd_and_hms(2026, 8, 21, 9, 0, 0).unwrap(); + assert!(!office.is_open(&friday), "Friday is not"); + // Numbers work as well as names, Monday being zero. + let by_number = Office::from_config(&serde_json::json!({"work_days": [0, 1, 2]})); + assert_eq!(by_number.days, vec![0, 1, 2]); + // And nonsense falls back rather than emptying the week. + let junk = Office::from_config(&serde_json::json!({"work_days": []})); + assert_eq!(junk.days, vec![0, 1, 2, 3, 4]); + // An hour that is not an hour used to unwrap on the first frame. + let wild = Office::from_config(&serde_json::json!({ + "work_start_hour": 24, + "work_end_hour": 99 + })); + assert!(wild.start <= 23 && wild.end <= 23); + let noon = Local.with_ymd_and_hms(2026, 8, 24, 12, 0, 0).unwrap(); + let _ = wild.next_open(&noon); + let _ = wild.prev_close(&noon); + let _ = countdowns(noon, &wild); + } + + #[test] + fn each_countdown_gets_its_own_colour() { + let now = Local.with_ymd_and_hms(2026, 8, 22, 14, 30, 0).unwrap(); + let items = countdowns(now, &Office::from_config(&serde_json::json!({}))); + let inks: std::collections::HashSet<String> = + items.iter().map(|c| c.ink.clone()).collect(); + assert_eq!(inks.len(), 3, "three clocks, three colours"); + } + + #[test] + fn the_world_clock_reads_more_than_light_and_dark() { + let p = palette(); + let tokyo: Tz = "Asia/Tokyo".parse().unwrap(); + let at = |h: u32, d: u32| { + tokyo.with_ymd_and_hms(2026, 8, d, h, 0, 0).unwrap() + }; + // Saturday the 22nd, Monday the 24th. + assert_eq!(phase_of(&at(3, 24), &p).1, "☾", "the small hours"); + assert_eq!(phase_of(&at(11, 24), &p), (p.work.clone(), "☀")); + assert_eq!(phase_of(&at(19, 24), &p), (p.eve.clone(), "☾")); + assert_eq!(phase_of(&at(8, 24), &p), (p.eve.clone(), "☀")); + // A weekday's working hours and a weekend's are different answers. + assert_eq!(phase_of(&at(11, 22), &p), (p.weekend.clone(), "☀")); + } + + #[test] + fn the_clock_is_two_colours_down_its_height() { + // The Python paints the top three rows bright and the base dark; + // one flat colour loses the weight of the digits entirely. + let p = palette(); + assert_ne!(p.big_top, p.big_base); + assert_eq!(p.big_top, tc::rgb(120, 255, 200)); + assert_eq!(p.big_base, tc::rgb(40, 150, 120)); + } + + #[test] + fn digits_are_five_rows_of_blocks() { + let rows = render_big("12:34"); + assert_eq!(rows.len(), 5); + // Every row is the same width, or the clock leans. + let widths: Vec<usize> = rows.iter().map(|r| r.chars().count()).collect(); + assert!(widths.windows(2).all(|w| w[0] == w[1]), "{:?}", widths); + assert!(rows[0].contains('█')); + } + + #[test] + fn an_unknown_character_leaves_a_gap_rather_than_panicking() { + let rows = render_big("1?2"); + assert_eq!(rows.len(), 5); + } + + #[test] + fn the_offset_is_written_as_people_write_it() { + let utc = Utc.with_ymd_and_hms(2026, 8, 22, 12, 0, 0).unwrap(); + let india: Tz = "Asia/Kolkata".parse().unwrap(); + // Half-hour offsets have to keep their minutes. + assert_eq!(offset_str(&utc.with_timezone(&india)), "UTC+5:30"); + let tokyo: Tz = "Asia/Tokyo".parse().unwrap(); + assert_eq!(offset_str(&utc.with_timezone(&tokyo)), "UTC+9"); + let la: Tz = "America/Los_Angeles".parse().unwrap(); + assert_eq!(offset_str(&utc.with_timezone(&la)), "UTC-7"); + } + + #[test] + fn daylight_saving_actually_moves_the_clock() { + // The whole reason for carrying a timezone database: London is one + // hour off UTC in August and level with it in January. + let london: Tz = "Europe/London".parse().unwrap(); + let summer = Utc.with_ymd_and_hms(2026, 8, 22, 12, 0, 0).unwrap(); + let winter = Utc.with_ymd_and_hms(2026, 1, 22, 12, 0, 0).unwrap(); + assert_eq!(offset_str(&summer.with_timezone(&london)), "UTC+1"); + assert_eq!(offset_str(&winter.with_timezone(&london)), "UTC+0"); + } + + #[test] + fn the_longest_countdown_label_still_leaves_a_gap() { + // "Start of Office Hour" is exactly twenty characters, and a + // twenty-wide field ran it straight into the time beside it. + let now = Local.with_ymd_and_hms(2026, 8, 22, 7, 0, 0).unwrap(); + let longest = countdowns(now, &Office::from_config(&serde_json::json!({}))) + .into_iter() + .map(|c| c.label.chars().count()) + .max() + .unwrap(); + assert!(longest < 21, "a label of {} needs a wider field", longest); + } + + #[test] + fn the_flash_blinks_and_then_stops() { + let started = Some(100.0); + // Lit at the start of each window, dark between them. + assert!(flash_window(started, 2, 1.0, 100.0)); + assert!(flash_window(started, 2, 1.0, 100.3)); + assert!(!flash_window(started, 2, 1.0, 100.5)); + assert!(flash_window(started, 2, 1.0, 101.1)); + // And over for good once the last blink has passed. + assert!(!flash_window(started, 2, 1.0, 102.0)); + assert!(!flash_window(None, 2, 1.0, 100.0)); + } + + #[test] + fn a_flashed_frame_is_solid_and_exactly_the_width() { + let rows = vec![ + format!("{}hello{}", tc::rgb(1, 2, 3), tc::RST), + "plain".to_string(), + ]; + let lit = flash_frame(&rows, 20, 3, &tc::bg(250, 250, 250), &tc::rgb(0, 0, 0)); + assert_eq!(lit.len(), 3); + for line in &lit { + // The original colours are gone; only the flash pair remains. + assert!(!line.contains("38;2;1;2;3")); + assert_eq!(strip_ansi(line).chars().count(), 20); + } + } + + #[test] + fn the_flash_picks_ink_it_can_be_read_through() { + // Near-white wants dark text; a dark colour wants light. + assert_eq!(flash_ink((246, 248, 252)), tc::rgb(18, 20, 26)); + assert_eq!(flash_ink((20, 20, 30)), tc::rgb(255, 240, 240)); + } + + #[test] + fn a_focus_block_leads_to_a_break_and_back() { + let _held = sandbox("a_focus_block_leads_to_a_break_and_back"); + let cfg = serde_json::json!({}); + let mut pomo = Pomodoro::new(&cfg); + let now = 1000.0; + assert_eq!(pomo.phase, Phase::Focus); + pomo.advance(now); + assert_eq!(pomo.phase, Phase::Short); + assert_eq!(pomo.done, 1); + pomo.advance(now); + assert_eq!(pomo.phase, Phase::Focus, "a break returns to focus"); + } + + #[test] + fn every_fourth_break_is_a_long_one() { + let _held = sandbox("every_fourth_break_is_a_long_one"); + let mut pomo = Pomodoro::new(&serde_json::json!({})); + let now = 0.0; + for _ in 0..3 { + pomo.advance(now); // focus -> short + pomo.advance(now); // short -> focus + } + pomo.advance(now); // the fourth focus block + assert_eq!(pomo.done, 4); + assert_eq!(pomo.phase, Phase::Long); + } + + #[test] + fn hiding_freezes_it_rather_than_letting_it_run_away() { + let _held = sandbox("hiding_freezes_it_rather_than_letting_it_run_away"); + let mut pomo = Pomodoro::new(&serde_json::json!({})); + pomo.shown = true; + pomo.start_stop(0.0); + assert!(pomo.running); + // Two minutes in, hide it, and leave it hidden for an hour. + pomo.toggle(120.0); + let frozen = pomo.left; + pomo.toggle(3720.0); + // What is left is what was left, not an hour less. + assert!((pomo.remaining(3720.0) - frozen).abs() < 0.001, + "left {} against frozen {}", pomo.remaining(3720.0), frozen); + assert!(pomo.running, "it was running when it went away"); + } + + #[test] + fn overrunning_counts_up_rather_than_stopping() { + let _held = sandbox("overrunning_counts_up_rather_than_stopping"); + let mut pomo = Pomodoro::new(&serde_json::json!({})); + pomo.shown = true; + pomo.start_stop(0.0); + let past = pomo.duration() + 90.0; + assert_eq!(pomo.remaining(past), 0.0); + assert!((pomo.overtime(past) - 90.0).abs() < 0.001); + // And it does not advance itself: a break that starts while you are + // mid-sentence is a break you ignore, and then the count is a lie. + assert_eq!(pomo.phase, Phase::Focus); + } + + #[test] + fn hms_counts_down_and_never_below_zero() { + assert_eq!(hms(3661), "01:01:01"); + assert_eq!(hms(59), "00:00:59"); + assert_eq!(hms(-5), "00:00:00"); + } + + #[test] + fn a_bar_is_exactly_its_width() { + for frac in [0.0, 0.25, 0.5, 1.0, 1.5, -0.2] { + assert_eq!(bar(frac, 20).chars().count(), 20, "frac {}", frac); + } + assert!(bar(0.0, 10).starts_with('░')); + assert!(bar(1.0, 10).starts_with('█')); + } + + #[test] + fn the_countdowns_stay_inside_their_spans() { + let now = Local.with_ymd_and_hms(2026, 8, 22, 14, 30, 0).unwrap(); + let items = countdowns(now, &Office::from_config(&serde_json::json!({}))); + assert_eq!(items.len(), 3); + for item in &items { + assert!(item.left > 0, "{} had {}s left", item.label, item.left); + assert!((0.0..=1.0).contains(&item.frac), "{} at {}", item.label, item.frac); + } + // Half past two leaves half an hour of this hour. + assert_eq!(items[0].left, 1800); + } + + #[test] + fn a_weekday_evening_counts_to_the_next_morning() { + // Thursday 20 August 2026 at nine in the evening: twelve hours to + // Friday's opening. This test previously used a Saturday and + // expected the same answer, which is how the missing weekend + // handling went unnoticed - the assertion encoded the bug. + let evening = Local.with_ymd_and_hms(2026, 8, 20, 21, 0, 0).unwrap(); + let items = countdowns(evening, &Office::from_config(&serde_json::json!({}))); + assert_eq!(items[1].label, "Start of Office Hour"); + assert_eq!(items[1].left, 12 * 3600); + } + + #[test] + fn a_day_the_clocks_move_is_not_twenty_four_hours() { + // Britain moves at one in the morning on the last Sunday of March + // and back on the last Sunday of October - in 2026, the 29th and + // the 25th. Held against a fixed 86,400 seconds, both days put the + // time remaining and the bar an hour out. + let london: Tz = "Europe/London".parse().unwrap(); + + let spring = london.with_ymd_and_hms(2026, 3, 29, 0, 30, 0).unwrap(); + let (into, len) = day_bounds(&spring); + assert_eq!(len, 23 * 3600, "the day the clocks go forward is 23 hours"); + // Half past midnight, with the jump still ahead: twenty-two and a + // half hours of the day left, not the twenty-three and a half a + // fixed day gives. + assert_eq!(len - into, 22 * 3600 + 1800); + + let autumn = london.with_ymd_and_hms(2026, 10, 25, 0, 30, 0).unwrap(); + let (into, len) = day_bounds(&autumn); + assert_eq!(len, 25 * 3600, "the day they go back is 25 hours"); + assert_eq!(len - into, 24 * 3600 + 1800); + + // And a day nothing happens on is still a plain day. + let plain = london.with_ymd_and_hms(2026, 8, 22, 6, 0, 0).unwrap(); + assert_eq!(day_bounds(&plain), (6 * 3600, 86_400)); + } + + #[test] + fn a_finished_toast_is_reaped_rather_than_left_a_zombie() { + // Every notification spawns a process, and one nobody waits on is a + // zombie for the life of the widget - an ignored pomodoro sends one + // a minute, so a panel left alone all day would leave hundreds. + let mut sent = Vec::new(); + for _ in 0..3 { + match std::process::Command::new("true") + .stdout(std::process::Stdio::null()) + .spawn() + { + Ok(child) => sent.push(child), + Err(_) => return, // nothing to spawn on this machine + } + } + // They exit in milliseconds, but "immediately" is not a promise the + // scheduler makes: allow a second before believing otherwise. + for _ in 0..100 { + reap(&mut sent); + if sent.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(sent.is_empty(), "{} toasts left unreaped", sent.len()); + } + + #[test] + fn the_timer_is_off_until_asked_for_and_never_starts_itself() { + let _held = sandbox("enabled"); + // With nothing configured there is nothing on screen, which is what + // the help text and the doc both promise. It was hardcoded shown. + let off = Pomodoro::new(&serde_json::json!({})); + assert!(!off.shown, "an optional panel was on screen unasked"); + assert!(!off.running); + + // pomodoro_enabled puts it there - paused. Setting `running` from + // it left the deadline at zero, and with no deadline the countdown + // reads a `left` that nothing decrements: a timer frozen at the + // full block with nothing on the row saying it was not moving. + let on = Pomodoro::new(&serde_json::json!({"pomodoro_enabled": true})); + assert!(on.shown); + assert!(!on.running, "a block nobody sat down for was being counted"); + let now = 1_000.0; + assert_eq!(on.remaining(now), on.duration()); + assert_eq!(on.remaining(now + 600.0), on.duration(), "a paused timer moved"); + } + + #[test] + fn the_hint_preference_is_not_the_pomodoros_visibility() { + // One field held both: "hints" in the state file was written from + // whether the panel was shown, so hiding the panel rewrote the [?] + // preference - which was itself never saved, and came back every + // restart. + let _held = sandbox("hints"); + let shown = serde_json::json!({"pomodoro_enabled": true}); + + let mut p = Pomodoro::new(&shown); + assert!(p.hints, "show_hints defaults to on"); + p.hints = false; // what [?] does + p.save(); + let back = Pomodoro::new(&shown); + assert!(!back.hints, "the hint preference did not survive a restart"); + assert!(back.shown, "hiding the hints hid the panel with them"); + + // And the other way about: hiding the panel leaves the hints alone. + let mut p = Pomodoro::new(&shown); + p.hints = true; + p.save(); + p.toggle(0.0); // what [p] does + assert!(!p.shown); + let back = Pomodoro::new(&shown); + assert!(!back.shown, "the panel came back after being hidden"); + assert!(back.hints, "hiding the panel rewrote the hint preference"); + } + + #[test] + fn a_nudge_moves_the_finish_line_rather_than_starting_the_block_over() { + // Twenty minutes into a twenty-five minute block, one more minute + // means six left. It used to mean twenty-six: the new length was + // assigned whole and the deadline rebuilt from now, so every nudge + // threw away however far in you were. + let _held = sandbox("nudge-keeps-progress"); + let mut pomo = Pomodoro::new(&serde_json::json!({})); + pomo.shown = true; + pomo.start_stop(0.0); + let twenty = 20.0 * 60.0; + pomo.adjust(1.0); + assert_eq!(pomo.remaining(twenty), 6.0 * 60.0); + + // Paused, where `left` is the number that counts. + pomo.start_stop(twenty); + assert_eq!(pomo.left, 6.0 * 60.0); + pomo.adjust(-2.0); + assert_eq!(pomo.left, 4.0 * 60.0); + + // Cut shorter than the time already spent and the block is over, + // which is true - the counter climbs rather than claiming minutes + // that have gone. + pomo.adjust(-20.0); + assert!(pomo.signed(twenty) < 0.0, "{} left", pomo.left); + } +} diff --git a/widgets/src/bin/clocks_help.txt b/widgets/src/bin/clocks_help.txt new file mode 100644 index 0000000..5da57d2 --- /dev/null +++ b/widgets/src/bin/clocks_help.txt @@ -0,0 +1,34 @@ +Clocks: this server's, everyone else's, and the ones counting down. + +A big clock in the machine's own timezone, countdown bars for the next hour, +office hours and the end of day, an optional pomodoro, and a world clock +covering the hubs you care about. + +The pomodoro is off until you press p. It runs the standard 25/5 with a longer +break every fourth session, all configurable, and persists across restarts so +relaunching the panel does not cost you a session. + +A phase does not end itself. When the time is up the counter keeps going, +showing how far over you are, and the bar rescales so a growing red section +represents the overrun — the longer you ignore it, the more of the bar is red. +The whole panel also flashes twice, a second apart, on every alert - visible +with the sound muted. The terminal is alerted when the phase elapses and again +every minute it keeps running: BEL plus OSC 9 and OSC 777 desktop notifications, which are the only +channels that survive SSH. Under Herdr it additionally raises a native toast +with a sound — additive, never required, and skipped entirely elsewhere. + +Keys: up/down (and PgUp/PgDn, Home/End) scroll the city list while the clock, +countdowns and footer stay pinned. p shows or hides the pomodoro and suspends +it with them, space pauses or +resumes, r restarts the phase, s starts a break during focus and ends one during +a break - b and e do the same, and the footer names whichever applies - +/- +change the focus length, ? hides or shows the pomodoro controls, +0 zeroes today's completed count, q quits. + +The completed tally is per day: it resets when the date changes, including +while the panel is running. Preferences - focus length, whether the timer is +shown - are not tied to the day and persist. + +Big digits show this server's system-timezone clock. Below it, each hub +is shown in its own timezone, sorted west to east, coloured by whether people +there are plausibly at work. diff --git a/widgets/src/bin/deployments.rs b/widgets/src/bin/deployments.rs new file mode 100644 index 0000000..6bad1ca --- /dev/null +++ b/widgets/src/bin/deployments.rs @@ -0,0 +1,1702 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Vercel deployments, across every team the token can see. +//! +//! A port of deployments.py. The first widget here to hold a secret: the +//! token comes from the git-ignored config or the environment, never from +//! the source tree, and never reaches a command line. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::{Local, TimeZone}; +use toys_core as tc; + +const API: &str = "https://api.vercel.com"; +/// How many build-log lines to ask for. Enough that the tail of a failing +/// build is in there whole, few enough not to wait on a novel. +const EVENT_LIMIT: usize = 200; +/// How long a fetched detail is kept before the open page asks again. A +/// running build's log grows while it is being read. +const DETAIL_TTL: f64 = 60.0; +const FILTERS: &[&str] = &["all", "failed", "production"]; +const SPARK: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +/// The states that mean something is happening right now. +const LIVE: &[&str] = &["BUILDING", "QUEUED", "INITIALIZING"]; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// A Vercel token, and where it came from. +/// +/// Deliberately not the Vercel CLI's session: that expires within hours and +/// only the CLI can refresh it, so a panel reading it goes dark overnight. +/// Create one at Account Settings -> Tokens instead. +fn token(cfg: &serde_json::Value) -> (String, &'static str) { + let from_config = tc::cfg_str(cfg, "token", ""); + if !from_config.is_empty() { + return (from_config, "config"); + } + let name = tc::cfg_str(cfg, "token_env", "VERCEL_TOKEN"); + let name = if name.is_empty() { "VERCEL_TOKEN".into() } else { name }; + match std::env::var(&name) { + Ok(value) if !value.is_empty() => (value, "env"), + _ => (String::new(), "missing"), + } +} + +fn api(path: &str, tok: &str) -> Result<serde_json::Value, String> { + let url = format!("{}{}", API, path); + let body = tc::get(&url, &[("Authorization", &format!("Bearer {}", tok))], 25)?; + serde_json::from_str(&body).map_err(|e| e.to_string()) +} + +/// The team ids in one page of `/v2/teams`, and the cursor for the next. +/// +/// Vercel pages every list endpoint the same way: `pagination.next` is the +/// timestamp to hand back as `until`, and it is null on the last page. +/// Split out so the paging can be tested without a token. +fn teams_page(res: &serde_json::Value) -> (Vec<String>, Option<i64>) { + let ids = res["teams"] + .as_array() + .into_iter() + .flatten() + .filter_map(|t| t["id"].as_str().map(String::from)) + .collect(); + let next = &res["pagination"]["next"]; + // Documented as a number; read as a string too, because a cursor that + // arrives quoted would otherwise stop the walk one page in and look + // exactly like an account with fewer teams in it. + let next = next + .as_i64() + .or_else(|| next.as_str().and_then(|s| s.parse().ok())); + (ids, next) +} + +/// How many pages of teams to walk before giving up on the cursor. +const TEAM_PAGES: usize = 20; + +/// Every team the token can see, so deployments are not just personal. +/// +/// All of them, which took a second version: the endpoint answers one page +/// at a time and the first version asked once. Deployments are then never +/// requested for the teams that were on the pages nobody asked for, while +/// the widget goes on describing itself as covering every team the token +/// can see - a partial board presented as a whole one. +/// +/// An empty list on failure is deliberate and stays: the personal scope +/// still works, and a widget that refused to start because one endpoint was +/// down would be worse than one showing fewer rows. The *silence* was not +/// deliberate, so what comes back beside the ids is why the walk stopped, +/// for the screen to say out loud. +fn discover_teams(tok: &str) -> (Vec<String>, Option<String>) { + walk_teams(|until| { + let mut path = "/v2/teams?limit=100".to_string(); + if let Some(mark) = until { + path += &format!("&until={}", mark); + } + api(&path, tok) + }) +} + +/// The walk itself, over whatever is answering. +/// +/// The fetch is a parameter so the paging can be tested without a token, +/// and paging is exactly what was wrong: one page was read and the rest of +/// the teams were never asked about. On this machine that is the only half +/// of this function anything can check. +fn walk_teams( + mut fetch: impl FnMut(Option<i64>) -> Result<serde_json::Value, String>, +) -> (Vec<String>, Option<String>) { + let mut ids: Vec<String> = Vec::new(); + let mut until: Option<i64> = None; + for _ in 0..TEAM_PAGES { + let res = match fetch(until) { + Ok(res) => res, + Err(said) => { + let stopped = if ids.is_empty() { + format!("could not list teams ({}) - personal scope only", said) + } else { + format!("team list stopped early ({}) - teams may be missing", said) + }; + return (ids, Some(stopped)); + } + }; + let (page, next) = teams_page(&res); + for id in page { + // The cursor is a creation timestamp and the boundary team can + // come back on both sides of it. A repeated id would be a + // repeated scope: the same deployments fetched twice and listed + // twice. + if !ids.contains(&id) { + ids.push(id); + } + } + match next { + None => return (ids, None), + // A cursor that has not moved would ask for the same page for + // ever. Stopping is right; stopping quietly is not. + Some(mark) if Some(mark) == until => { + return ( + ids, + Some("team list stopped early (the cursor stopped moving)".into()), + ) + } + Some(mark) => until = Some(mark), + } + } + ( + ids, + Some(format!( + "team list stopped early (more than {} pages) - teams may be missing", + TEAM_PAGES + )), + ) +} + +/// Per-deployment detail: why it failed, timings, regions, aliases. +/// +/// The list endpoint carries none of this, so it is fetched when the info +/// view opens rather than for two hundred deployments nobody asked about. +fn fetch_detail(uid: &str, team: &str, tok: &str) -> serde_json::Value { + let mut path = format!("/v13/deployments/{}", uid); + if !team.is_empty() { + path += &format!("?teamId={}", team); + } + let mut got = match api(&path, tok) { + Ok(value) => value, + Err(e) => serde_json::json!({ "_error": e }), + }; + // The build log, on the same trip. errorMessage says a build failed and + // names a code; the log says which line of somebody's config did it, + // which is the thing you would otherwise open a browser for. + if let Some(map) = got.as_object_mut() { + map.insert("_events".into(), fetch_events(uid, team, tok)); + map.insert("_fetched_at".into(), serde_json::json!(now())); + } + got +} + +/// The build log for one deployment, newest last. +/// +/// Capped on the way in: a long build runs to thousands of lines and the +/// pane shows a few dozen, so asking for all of them would spend the wait +/// on text nobody sees. The tail is the useful end - a build says why it +/// failed on its way out, not on its way in. +fn fetch_events(uid: &str, team: &str, tok: &str) -> serde_json::Value { + let mut path = format!("/v3/deployments/{}/events?limit={}", uid, EVENT_LIMIT); + if !team.is_empty() { + path += &format!("&teamId={}", team); + } + match api(&path, tok) { + Ok(value) => value, + Err(e) => serde_json::json!({ "_error": e }), + } +} + +#[derive(Default)] +struct State { + deployments: Vec<serde_json::Value>, + err: String, + fetched: f64, +} + +fn text(value: &serde_json::Value, key: &str) -> String { + value[key].as_str().unwrap_or("").to_string() +} + +fn ms_at(value: &serde_json::Value, key: &str) -> Option<f64> { + value[key].as_f64() +} + +/// How long ago, on this widget's own scale. +fn age(ms: Option<f64>) -> String { + let Some(ms) = ms else { + return "--".to_string(); + }; + let s = (now() - ms / 1000.0).max(0.0); + if s < 90.0 { + format!("{}s", s as i64) + } else if s < 5400.0 { + format!("{}m", (s / 60.0) as i64) + } else if s < 172_800.0 { + format!("{}h", (s / 3600.0) as i64) + } else { + format!("{}d", (s / 86400.0) as i64) + } +} + +fn dur(seconds: Option<f64>) -> String { + let Some(s) = seconds else { + return " -- ".to_string(); + }; + if s < 60.0 { + format!("{:>5.0}s", s) + } else { + format!("{}m{:02}s", (s / 60.0) as i64, (s as i64) % 60) + } +} + +/// How long a build took, or has been taking. +fn build_seconds(d: &serde_json::Value) -> Option<f64> { + let building = ms_at(d, "buildingAt")?; + if let Some(ready) = ms_at(d, "ready") { + return Some((ready - building) / 1000.0); + } + if LIVE.contains(&text(d, "state").as_str()) { + return Some(now() - building / 1000.0); + } + None +} + +fn wrap(t: &str, width: usize) -> Vec<String> { + let chars: Vec<char> = t.chars().collect(); + if chars.is_empty() || width == 0 { + return vec![String::new()]; + } + chars + .chunks(width) + .map(|c| c.iter().collect()) + .collect() +} + +fn when(ms: Option<f64>) -> String { + let Some(ms) = ms else { + return String::new(); + }; + match Local.timestamp_opt((ms / 1000.0) as i64, 0).single() { + Some(at) => at.format("%Y-%m-%d %H:%M").to_string(), + None => String::new(), + } +} + +/// Everything worth copying out of a deployment. +fn copy_items(dep: &serde_json::Value, detail: Option<&serde_json::Value>) -> Vec<(String, String)> { + let meta = &dep["meta"]; + let mut items = Vec::new(); + let mut push = |label: &str, value: String| { + if !value.is_empty() { + items.push((label.to_string(), value)); + } + }; + push("Deployment dashboard", text(dep, "inspectorUrl")); + let branch_alias = text(meta, "branchAlias"); + if !branch_alias.is_empty() { + push("Branch preview", format!("https://{}", branch_alias)); + } + let url = text(dep, "url"); + if !url.is_empty() { + push("Commit preview", format!("https://{}", url)); + } + let (pr, org, repo) = ( + text(meta, "githubPrId"), + text(meta, "githubOrg"), + text(meta, "githubRepo"), + ); + if !pr.is_empty() && !org.is_empty() && !repo.is_empty() { + push( + "Pull request", + format!("https://github.com/{}/{}/pull/{}", org, repo, pr), + ); + } + push("Commit SHA", text(meta, "githubCommitSha")); + push("Branch name", text(meta, "githubCommitRef")); + if let Some(d) = detail { + push("Error message", text(d, "errorMessage")); + } + items +} + +/// Progressive disclosure: spend extra width on more content, not padding. +/// +/// Under 66 columns only the essentials fit. Above that the commit SHA and +/// branch appear. From 110 the metadata and commit subject share one line, +/// so twice as many deployments are visible in the same height. +struct Columns { + detail: bool, + single: bool, + project: usize, + branch: usize, +} + +fn columns(w: usize) -> Columns { + Columns { + detail: w >= 66, + single: w >= 110, + project: if w < 80 { + 12 + } else if w < 110 { + 16 + } else { + 20 + }, + branch: (w / 5).clamp(12, 34), + } +} + +/// Deployments per time bucket, coloured by the worst outcome in it. +fn activity(deps: &[serde_json::Value], w: usize, hours: f64, p: &Palette) -> (String, usize) { + let cols = w.saturating_sub(2).max(10); + let at = now() * 1000.0; + let span = hours * 3_600_000.0; + let mut buckets: Vec<Vec<&serde_json::Value>> = vec![Vec::new(); cols]; + for d in deps { + let off = at - ms_at(d, "created").unwrap_or(at); + if (0.0..span).contains(&off) { + let slot = cols - 1 - (off / span * cols as f64) as usize; + buckets[slot.min(cols - 1)].push(d); + } + } + let peak = buckets.iter().map(|b| b.len()).max().unwrap_or(0); + if peak == 0 { + return ( + tc::seg( + &[( + p.dim.as_str(), + format!(" no deployments in the last {}h", hours as i64), + )], + w - 1, + ), + 0, + ); + } + let mut parts: Vec<(&str, String)> = vec![(p.dim.as_str(), " ".into())]; + for bucket in &buckets { + if bucket.is_empty() { + parts.push((p.grid.as_str(), "·".into())); + continue; + } + let states: HashSet<String> = bucket.iter().map(|d| text(d, "state")).collect(); + let colour = if states.contains("ERROR") { + &p.error + } else if LIVE.iter().any(|s| states.contains(*s)) { + &p.build + } else { + &p.ready + }; + let level = ((bucket.len() as f64 / peak as f64) * 7.99) as usize; + parts.push((colour.as_str(), SPARK[level.min(7)].to_string())); + } + (tc::seg(&parts, w - 1), peak) +} + +struct Palette { + ready: String, + build: String, + error: String, + queue: String, + cancel: String, + dim: String, + grid: String, + msg: String, + url: String, + hint: String, + txt: String, + lbl: String, + prod: String, + sha: String, + branch: String, + accent: String, +} + +fn palette() -> Palette { + Palette { + ready: tc::rgb(80, 235, 150), + build: tc::rgb(255, 200, 90), + error: tc::rgb(255, 95, 105), + queue: tc::rgb(120, 160, 220), + cancel: tc::rgb(140, 145, 160), + dim: tc::rgb(127, 147, 172), + grid: tc::rgb(71, 91, 116), + msg: tc::rgb(158, 174, 196), + url: tc::rgb(130, 200, 255), + hint: tc::rgb(126, 148, 173), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + prod: tc::rgb(120, 180, 255), + sha: tc::rgb(190, 170, 255), + branch: tc::rgb(150, 210, 255), + accent: tc::rgb(150, 210, 255), + } +} + +fn state_colour<'a>(state: &str, p: &'a Palette) -> &'a str { + match state { + "READY" => &p.ready, + "BUILDING" => &p.build, + // Initializing is before the build starts, which is why + // deployments.py groups it with the queue rather than the build. + // Grouping it with BUILDING put the two panes on different colours + // for the same state. + "INITIALIZING" => &p.queue, + "ERROR" => &p.error, + "QUEUED" => &p.queue, + "CANCELED" => &p.cancel, + _ => &p.dim, + } +} + +/// Title case, which is all the Python's `.title()` is doing to a state. +fn titled(state: &str) -> String { + let mut chars = state.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(), + None => String::new(), + } +} + +/// One deployment in full: state, timings, why it failed, and what to copy. + +/// Whether one deployment answers what was typed after `/`. +/// +/// Over the fields the row puts on screen and the one it does not: project, +/// state, target, branch and commit subject, plus the deployment id, because +/// that is what a URL from somewhere else gives you to look up. The project +/// filter this replaced could only ever say one name at a time and had to be +/// cycled through every project to reach the last one. +fn matches(dep: &serde_json::Value, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + let meta = &dep["meta"]; + let hay = [ + text(dep, "name"), + text(dep, "state"), + text(dep, "target"), + text(dep, "uid"), + text(meta, "githubCommitRef"), + text(meta, "githubCommitMessage"), + ] + .join(" ") + .to_lowercase(); + hay.contains(&needle.to_lowercase()) +} + +/// The build log, newest last, with what the build wrote to stderr picked +/// out of what it wrote to stdout. +/// +/// Vercel returns the lines oldest-first and a build explains itself on the +/// way out, so the tail is what matters - the last error before it gave up. +/// Long lines wrap rather than clip: a stack trace cut at the pane edge is +/// the half without the path in it. +fn build_log(detail: &serde_json::Value, w: usize, p: &Palette) -> Vec<String> { + let events = &detail["_events"]; + if let Some(err) = events.get("_error").and_then(|e| e.as_str()) { + return vec![ + String::new(), + tc::seg(&[(p.lbl.as_str(), " ── BUILD LOG ──".into())], w - 1), + tc::seg(&[(p.dim.as_str(), format!(" {}", err))], w - 1), + ]; + } + let Some(lines) = events.as_array() else { + return Vec::new(); + }; + if lines.is_empty() { + return Vec::new(); + } + let mut rows = vec![ + String::new(), + tc::seg( + &[ + (p.lbl.as_str(), " ── BUILD LOG ──".into()), + ( + p.dim.as_str(), + format!(" {} line{}", lines.len(), if lines.len() == 1 { "" } else { "s" }), + ), + ], + w - 1, + ), + ]; + for event in lines { + let said = text(event, "text"); + let said = said.trim_end(); + if said.is_empty() { + continue; + } + let colour = if text(event, "type") == "stderr" { + p.error.as_str() + } else { + p.dim.as_str() + }; + for line in wrap(said, w.saturating_sub(4).max(10)) { + rows.push(tc::seg(&[(colour, format!(" {}", line))], w - 1)); + } + } + rows +} + +fn info_overlay( + dep: &serde_json::Value, + detail: Option<&serde_json::Value>, + w: usize, + h: usize, + note: &str, + p: &Palette, +) -> Vec<String> { + let meta = &dep["meta"]; + let empty = serde_json::Value::Null; + let d = detail.unwrap_or(&empty); + let mut rows = vec![tc::title("deployment", w, &p.prod)]; + + macro_rules! field { + ($label:expr, $value:expr, $colour:expr) => {{ + let value: String = $value; + if !value.is_empty() { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {:<11}", $label)), + ($colour, value), + ], + w - 1, + )); + } + }}; + } + + let state = text(dep, "state"); + field!("project", text(dep, "name"), p.accent.as_str()); + let step = text(d, "errorStep"); + field!( + "state", + format!( + "{}{}", + titled(&state), + if step.is_empty() { + String::new() + } else { + format!(" ({})", step) + } + ), + state_colour(&state, p) + ); + let production = text(dep, "target") == "production"; + field!( + "target", + if production { "production" } else { "preview" }.to_string(), + if production { p.prod.as_str() } else { p.dim.as_str() } + ); + field!( + "created", + when(ms_at(dep, "created").or_else(|| ms_at(dep, "createdAt"))), + p.txt.as_str() + ); + if let Some(secs) = build_seconds(dep) { + let queued = match (ms_at(dep, "createdAt"), ms_at(dep, "buildingAt")) { + (Some(made), Some(built)) => (built - made) / 1000.0, + _ => 0.0, + }; + field!( + "build", + format!( + "{}{}", + dur(Some(secs)).trim(), + if queued > 0.5 { + format!(" queued {:.0}s", queued) + } else { + String::new() + } + ), + p.txt.as_str() + ); + } + let regions: Vec<String> = d["regions"] + .as_array() + .into_iter() + .flatten() + .filter_map(|r| r.as_str().map(String::from)) + .collect(); + if !regions.is_empty() { + let plan = text(d, "plan"); + field!( + "regions", + format!( + "{}{}", + regions.join(", "), + if plan.is_empty() { + String::new() + } else { + format!(" plan {}", plan) + } + ), + p.txt.as_str() + ); + } + let aliases = d["alias"].as_array().map(|a| a.len()).unwrap_or(0); + if aliases > 0 { + field!("aliases", format!("{} assigned", aliases), p.dim.as_str()); + } + + let (message, code) = (text(d, "errorMessage"), text(d, "errorCode")); + if !message.is_empty() || !code.is_empty() { + rows.push(String::new()); + rows.push(tc::seg(&[(p.lbl.as_str(), " ── WHY IT FAILED ──".into())], w - 1)); + if !code.is_empty() { + rows.push(tc::seg(&[(p.error.as_str(), format!(" {}", code))], w - 1)); + } + for line in wrap(&message, w.saturating_sub(4).max(10)) { + rows.push(tc::seg(&[(p.msg.as_str(), format!(" {}", line))], w - 1)); + } + let link = text(d, "errorLink"); + if !link.is_empty() { + rows.push(tc::seg(&[(p.url.as_str(), format!(" {}", link))], w - 1)); + } + } else if !text(d, "_error").is_empty() { + rows.push(String::new()); + rows.push(tc::seg( + &[( + p.error.as_str(), + format!(" detail unavailable: {}", text(d, "_error")), + )], + w - 1, + )); + } else if d.is_null() { + rows.push(String::new()); + rows.push(tc::seg(&[(p.dim.as_str(), " loading detail…".into())], w - 1)); + } + + let sha = text(meta, "githubCommitSha"); + if !sha.is_empty() { + rows.push(String::new()); + rows.push(tc::seg(&[(p.lbl.as_str(), " ── COMMIT ──".into())], w - 1)); + rows.push(tc::seg( + &[ + (p.sha.as_str(), format!(" {}", &sha[..sha.len().min(7)])), + ( + p.branch.as_str(), + format!(" {}", text(meta, "githubCommitRef")), + ), + ], + w - 1, + )); + let subject = text(meta, "githubCommitMessage"); + let subject = subject.lines().next().unwrap_or(""); + for line in wrap(subject, w.saturating_sub(4).max(10)) { + rows.push(tc::seg(&[(p.msg.as_str(), format!(" {}", line))], w - 1)); + } + let who = match text(meta, "githubCommitAuthorName") { + name if !name.is_empty() => name, + _ => text(meta, "githubCommitAuthorLogin"), + }; + if !who.is_empty() { + rows.push(tc::seg(&[(p.dim.as_str(), format!(" by {}", who))], w - 1)); + } + } + + // The build log, last. It is the longest thing on the screen and the + // one most often scrolled to, so everything that fits in a line of its + // own goes above it. + if let Some(detail) = detail { + rows.extend(build_log(detail, w, &p)); + } + + // The copy list is its own page now, reached with c. It was a section + // at the bottom of this one, which meant the numbers you press were + // usually scrolled off the screen by the build log above them. + let pairs: Vec<(String, String)> = Vec::new(); + if !pairs.is_empty() { + rows.push(String::new()); + rows.push(tc::seg(&[(p.lbl.as_str(), " ── COPY ──".into())], w - 1)); + for (i, (label, value)) in pairs.iter().enumerate() { + let room = w.saturating_sub(28); + let short = if value.chars().count() <= room { + value.clone() + } else { + format!( + "{}…", + value.chars().take(w.saturating_sub(31)).collect::<String>() + ) + }; + rows.push(tc::seg( + &[ + (p.ready.as_str(), format!(" [{}] ", i + 1)), + (p.txt.as_str(), format!("{:<21} ", label)), + (p.url.as_str(), short), + ], + w - 1, + )); + } + } + + let _ = (h, note); + rows +} + +/// The copy list on a page of its own. +/// +/// It used to sit under the detail, which was fine until the build log went +/// in above it: the numbers you press were then reliably scrolled off the +/// bottom of the screen you were pressing them from. +fn copy_overlay( + dep: &serde_json::Value, + detail: Option<&serde_json::Value>, + w: usize, + h: usize, + note: &str, + p: &Palette, +) -> Vec<String> { + let pairs = copy_items(dep, detail); + let mut rows = vec![tc::title("copy", w, &p.prod)]; + rows.push(String::new()); + if pairs.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " nothing here to copy yet".into())], + w - 1, + )); + } + for (i, (label, value)) in pairs.iter().enumerate() { + let room = w.saturating_sub(28); + let short = if value.chars().count() <= room { + value.clone() + } else { + format!("{}…", value.chars().take(room.saturating_sub(1)).collect::<String>()) + }; + rows.push(tc::seg( + &[ + (p.ready.as_str(), format!(" [{}] ", i + 1)), + (p.txt.as_str(), format!("{:<21} ", label)), + (p.url.as_str(), short), + ], + w - 1, + )); + } + while rows.len() < h.saturating_sub(2) { + rows.push(String::new()); + } + rows.push(tc::seg( + &[( + p.hint.as_str(), + if pairs.is_empty() { + " ← or esc to close · [q]uit".to_string() + } else { + format!(" press 1-{} to copy · ← or esc to close · [q]uit", pairs.len()) + }, + )], + w - 1, + )); + rows.push(if note.is_empty() { + String::new() + } else { + tc::seg(&[(p.ready.as_str(), format!(" {}", note))], w - 1) + }); + rows +} + +fn main() { + tc::maybe_help(include_str!("deployments_help.txt")); + let cfg = tc::load_config("deployments"); + let mut refresh = tc::poll_secs(tc::cfg_f64(&cfg, "refresh", 15.0), 15.0).max(5.0); + let limit = tc::cfg_usize(&cfg, "limit", 100); + let mut teams = tc::cfg_strings(&cfg, "teams", &[]); + let configured: Vec<String> = tc::cfg_strings(&cfg, "projects", &[]); + + let args: Vec<String> = std::env::args().skip(1).collect(); + let mut named: Vec<String> = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-n" | "--refresh" if i + 1 < args.len() => { + refresh = tc::poll_secs(args[i + 1].parse().unwrap_or(15.0), 15.0).max(5.0); + i += 2; + } + "-t" | "--team" if i + 1 < args.len() => { + teams.push(args[i + 1].clone()); + i += 2; + } + other if !other.starts_with('-') => { + named.push(other.to_string()); + i += 1; + } + _ => i += 1, + } + } + let projects: HashSet<String> = if named.is_empty() { + configured.into_iter().collect() + } else { + named.into_iter().collect() + }; + + let absent = tc::missing(&["curl"]); + if !absent.is_empty() { + tc::cannot_start( + "vercel deployments", + &absent, + &[ + "Everything here comes from Vercel's HTTP API, and curl is how", + "this reaches it - the same way the other widgets reach ss,", + "ping and tailscale.", + "", + "The token is passed to curl on its standard input rather than", + "in its arguments, because /proc/<pid>/cmdline is readable by", + "every user on the machine.", + ], + "apt install curl", + ); + return; + } + + let p = palette(); + let (tok, source) = token(&cfg); + let env_name = { + let name = tc::cfg_str(&cfg, "token_env", "VERCEL_TOKEN"); + if name.is_empty() { "VERCEL_TOKEN".to_string() } else { name } + }; + // Why the discovered scope is not everything, when it is not. Kept for + // the poller rather than said once: `err` is rebuilt every round, and a + // board that is missing a team goes on missing it every round too. + let mut scope_note: Option<String> = None; + if !tok.is_empty() && teams.is_empty() { + let (found, stopped) = discover_teams(&tok); + teams = found; + scope_note = stopped; + } + + let state = Arc::new(Mutex::new(State::default())); + let wake = Arc::new((Mutex::new(false), Condvar::new())); + let poller = Arc::clone(&state); + let poller_wake = Arc::clone(&wake); + let poll_teams = teams.clone(); + let poll_projects = projects.clone(); + let poll_token = tok.clone(); + let poll_env = env_name.clone(); + let poll_scope = scope_note.clone(); + std::thread::spawn(move || loop { + if poll_token.is_empty() { + if let Ok(mut guard) = poller.lock() { + guard.err = format!( + "no token: set deployments.token in config.json, or ${}", + poll_env + ); + } + } else { + let mut out: Vec<serde_json::Value> = Vec::new(); + // A file others can read is worth saying out loud, since this + // is the widget that put a token in it. + let mut err = if source == "config" { + tc::config_token_warning().unwrap_or_default() + } else { + String::new() + }; + let scopes: Vec<String> = if poll_teams.is_empty() { + vec![String::new()] + } else { + poll_teams.clone() + }; + for team in &scopes { + let mut path = format!("/v6/deployments?limit={}", limit); + if !team.is_empty() { + path += &format!("&teamId={}", team); + } + match api(&path, &poll_token) { + Ok(res) => { + for d in res["deployments"].as_array().into_iter().flatten() { + let mut d = d.clone(); + // Carried so the detail request knows its scope. + d["_team"] = serde_json::Value::String(team.clone()); + out.push(d); + } + } + Err(said) => { + err = if said.contains("401") || said.contains("403") { + format!("{} (token expired? make a new one)", said) + } else { + said + }; + } + } + } + if !poll_projects.is_empty() { + out.retain(|d| poll_projects.contains(&text(d, "name"))); + } + out.sort_by(|a, b| { + ms_at(b, "created") + .unwrap_or(0.0) + .total_cmp(&ms_at(a, "created").unwrap_or(0.0)) + }); + if let Ok(mut guard) = poller.lock() { + // A failed round keeps the last good list rather than + // blanking the board: stale rows with a message beside them + // say more than an empty screen does. Judged on this round's + // own error, before the standing one is added to it: a scope + // that was never complete is not a round that failed. + if !out.is_empty() || err.is_empty() { + guard.deployments = out; + guard.fetched = now(); + } + // A scope that was never complete is a caveat about which + // teams are being asked at all, so it goes in front of + // whatever this round has to say rather than under it. + guard.err = match (&poll_scope, err.is_empty()) { + (None, _) => err, + (Some(said), true) => said.clone(), + (Some(said), false) => format!("{} · {}", said, err), + }; + } + } + let (lock, cond) = &*poller_wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(refresh)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + *asked = false; + }); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let details: Arc<Mutex<HashMap<String, serde_json::Value>>> = + Arc::new(Mutex::new(HashMap::new())); + let fetching: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new())); + let mut filter = 0usize; + let (mut needle, mut typing) = (String::new(), false); + let mut overlay = false; + let (mut tick, mut selected, mut scroll) = (0usize, 0usize, 0usize); + // The copy list is a page of its own, opened from the detail with c. + let mut copying = false; + // How far down the detail is scrolled. The build log makes it taller + // than any pane, so it has to move. + let mut oscroll = 0usize; + let mut note: (String, f64) = (String::new(), 0.0); + let mut visible = 1usize; + let mut shown: Vec<serde_json::Value> = Vec::new(); + + loop { + tick += 1; + for key in keyboard.poll() { + // While filtering, keys are text - only escape and enter are + // navigation, or the filter could never contain "q". + if typing && !overlay { + match key.as_str() { + "esc" => { + needle.clear(); + typing = false; + } + "enter" => typing = false, + "backspace" => { + needle.pop(); + } + other if other.chars().count() == 1 => needle.push_str(other), + _ => {} + } + selected = 0; + continue; + } + if overlay { + match key.as_str() { + // Left and esc come out. q quits outright, which is + // what the footer has always promised and what it + // does from the list - it used to close the overlay + // instead, so the key disagreed with its own hint. + "left" | "esc" => { + if copying { + copying = false; + } else { + overlay = false; + } + } + "c" | "C" if !copying => copying = true, + "up" | "k" | "K" if !copying => oscroll = oscroll.saturating_sub(1), + "down" | "j" | "J" if !copying => oscroll = oscroll.saturating_add(1), + "pgup" if !copying => { + let page = tc::size().1.saturating_sub(3).max(1); + oscroll = oscroll.saturating_sub(page); + } + "pgdn" if !copying => { + let page = tc::size().1.saturating_sub(3).max(1); + oscroll = oscroll.saturating_add(page); + } + "home" if !copying => oscroll = 0, + "end" if !copying => oscroll = usize::MAX, + // The detail is a live thing too - a running build's log + // grows while you read it. r drops what was fetched so + // the next frame asks again. + "r" | "R" if !copying => { + if let Some(chosen) = shown.get(selected.min(shown.len().saturating_sub(1))) + { + let uid = text(chosen, "uid"); + if let Ok(mut g) = details.lock() { + g.remove(&uid); + } + } + } + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + digit if digit.len() == 1 && digit.chars().all(|c| c.is_ascii_digit()) => { + if let Some(chosen) = shown.get(selected.min(shown.len().saturating_sub(1))) + { + let uid = text(chosen, "uid"); + let held = details.lock().ok().and_then(|g| g.get(&uid).cloned()); + let pairs = copy_items(chosen, held.as_ref()); + let at = digit.parse::<usize>().unwrap_or(0); + if at >= 1 && at <= pairs.len() { + let (label, value) = &pairs[at - 1]; + note = ( + if tc::clipboard(value) { + format!("✓ copied {}", label.to_lowercase()) + } else { + "! no clipboard; select the text with the mouse".into() + }, + now() + 3.0, + ); + } + } + } + _ => {} + } + continue; + } + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "r" | "R" => { + let (lock, cond) = &*wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + } + // s, not f: "filter" said nothing about which of the two + // this was once there were two, and the state filter is the + // one that cycles rather than types. + "s" | "S" => { + filter = (filter + 1) % FILTERS.len(); + selected = 0; + } + "/" => typing = true, + "up" => selected = selected.saturating_sub(1), + "down" => selected += 1, + "pgup" => selected = selected.saturating_sub(visible), + "pgdn" => selected += visible, + "home" => selected = 0, + "end" => selected = shown.len().saturating_sub(1), + "right" | "enter" => { + if !shown.is_empty() { + overlay = true; + copying = false; + oscroll = 0; + note = (String::new(), 0.0); + } + } + _ => {} + } + } + + let (w, h) = tc::size(); + let (deps, err, fetched) = match state.lock() { + Ok(g) => (g.deployments.clone(), g.err.clone(), g.fetched), + Err(_) => return, + }; + if !note.0.is_empty() && now() > note.1 { + note = (String::new(), 0.0); + } + shown = deps.clone(); + shown.retain(|d| matches(d, &needle)); + match FILTERS[filter] { + "failed" => shown.retain(|d| { + let s = text(d, "state"); + s == "ERROR" || s == "CANCELED" + }), + "production" => shown.retain(|d| text(d, "target") == "production"), + _ => {} + } + if !shown.is_empty() && selected >= shown.len() { + selected = shown.len() - 1; + } + + if overlay && !shown.is_empty() { + let chosen = shown[selected].clone(); + let uid = text(&chosen, "uid"); + let mut held = details.lock().ok().and_then(|g| g.get(&uid).cloned()); + // A build that is still running writes more log while you read + // it, so what was fetched when the page opened stops being the + // whole story. Dropped after DETAIL_TTL so the next frame asks + // again; a finished deployment is refetched too, which costs one + // request a minute and keeps the rule simple. + if held + .as_ref() + .map(|v| now() - v["_fetched_at"].as_f64().unwrap_or(0.0) > DETAIL_TTL) + .unwrap_or(false) + { + if let Ok(mut g) = details.lock() { + g.remove(&uid); + } + held = None; + } + if held.is_none() && !uid.is_empty() { + let start = fetching + .lock() + .map(|mut g| g.insert(uid.clone())) + .unwrap_or(false); + if start { + let (details, fetching) = (Arc::clone(&details), Arc::clone(&fetching)); + let (uid, team, tok) = (uid.clone(), text(&chosen, "_team"), tok.clone()); + std::thread::spawn(move || { + let got = fetch_detail(&uid, &team, &tok); + if let Ok(mut g) = details.lock() { + g.insert(uid.clone(), got); + } + if let Ok(mut g) = fetching.lock() { + g.remove(&uid); + } + }); + } + } + let rows = if copying { + copy_overlay(&chosen, held.as_ref(), w, h, ¬e.0, &p) + } else { + let body = info_overlay(&chosen, held.as_ref(), w, h, ¬e.0, &p); + let foot = 2; + let room = h.saturating_sub(foot).max(1); + let furthest = body.len().saturating_sub(room); + oscroll = oscroll.min(furthest); + let last = (oscroll + room).min(body.len()); + let mut out: Vec<String> = body[oscroll..last].to_vec(); + while out.len() < room { + out.push(String::new()); + } + out.push(tc::seg( + &[( + p.hint.as_str(), + if furthest > 0 { + format!( + " ↑↓ scroll {}-{} of {} · [c]opy · [r]efresh · ← esc · [q]uit", + oscroll + 1, + last, + body.len() + ) + } else { + " [c]opy · [r]efresh · ← or esc to close · [q]uit".to_string() + }, + )], + w - 1, + )); + out.push(if note.0.is_empty() { + String::new() + } else { + tc::seg(&[(p.ready.as_str(), format!(" {}", note.0))], w - 1) + }); + out + }; + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(100)); + continue; + } + + let mut states: HashMap<String, usize> = HashMap::new(); + for d in &deps { + *states.entry(text(d, "state")).or_insert(0) += 1; + } + let seen_projects: HashSet<String> = deps.iter().map(|d| text(d, "name")).collect(); + let live: usize = LIVE + .iter() + .map(|s| states.get(*s).copied().unwrap_or(0)) + .sum(); + + let mut rows = vec![tc::title("vercel deployments", w, &p.prod)]; + let mut head = vec![ + (p.dim.as_str(), format!(" {} deploys", deps.len())), + (p.dim.as_str(), format!(" · {} proj", seen_projects.len())), + ( + p.ready.as_str(), + format!(" {} ready", states.get("READY").copied().unwrap_or(0)), + ), + ]; + if let Some(n) = states.get("ERROR") { + head.push((p.error.as_str(), format!(" {} error", n))); + } + if live > 0 { + head.push(( + p.build.as_str(), + format!(" {} {} building", SPINNER[tick % SPINNER.len()], live), + )); + } + head.push(( + p.dim.as_str(), + format!( + " {} ago", + if fetched > 0.0 { + age(Some(fetched * 1000.0)) + } else { + "--".into() + } + ), + )); + rows.push(tc::seg(&head, w - 1)); + if !err.is_empty() { + rows.push(tc::seg(&[(p.error.as_str(), format!(" ! {}", err))], w - 1)); + } + let mut bits: Vec<String> = Vec::new(); + if FILTERS[filter] != "all" { + bits.push(FILTERS[filter].to_string()); + } + if !needle.is_empty() { + bits.push(format!("/{}", needle)); + } + if !bits.is_empty() || typing { + // The cursor is the widget saying it is still listening: without + // it an empty filter and a filter you are halfway through typing + // look identical. + rows.push(tc::seg( + &[ + (p.build.as_str(), format!(" filter: {}", bits.join(" + "))), + (p.build.as_str(), if typing { "▏".into() } else { String::new() }), + ( + p.dim.as_str(), + if typing { " enter to keep · esc to clear".into() } else { String::new() }, + ), + ], + w - 1, + )); + } + rows.push(String::new()); + + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── ACTIVITY ── ".into()), + (p.dim.as_str(), "deploys/hour, last 48h".into()), + ], + w - 1, + )); + let (chart, peak) = activity(&deps, w, 48.0, &p); + rows.push(chart); + if peak > 0 { + rows.push(tc::seg( + &[ + (p.dim.as_str(), " 48h ago".into()), + (p.dim.as_str(), " ".repeat(w.saturating_sub(22).max(1))), + (p.dim.as_str(), format!("peak {}/h", peak)), + ], + w - 1, + )); + } + + let mut durs: Vec<f64> = deps.iter().filter_map(build_seconds).collect(); + durs.sort_by(f64::total_cmp); + if !durs.is_empty() { + let med = durs[durs.len() / 2]; + let p95 = durs[(durs.len() - 1).min((durs.len() as f64 * 0.95) as usize)]; + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── BUILD TIME ── ".into()), + (p.dim.as_str(), "median ".into()), + (p.txt.as_str(), dur(Some(med))), + (p.dim.as_str(), " p95 ".into()), + (p.txt.as_str(), dur(Some(p95))), + (p.dim.as_str(), " max ".into()), + (p.txt.as_str(), dur(durs.last().copied())), + ], + w - 1, + )); + let recent: Vec<f64> = deps + .iter() + .take(w.saturating_sub(2).max(10)) + .filter_map(build_seconds) + .collect::<Vec<f64>>() + .into_iter() + .rev() + .collect(); + if !recent.is_empty() { + let hi = recent.iter().cloned().fold(0.0f64, f64::max).max(1e-9); + let spark: String = recent + .iter() + .map(|x| SPARK[(((x / hi) * 7.99) as usize).min(7)]) + .collect(); + rows.push(tc::seg(&[(p.ready.as_str(), format!(" {}", spark))], w - 1)); + } + } + rows.push(String::new()); + + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── RECENT ── ".into()), + ( + p.dim.as_str(), + if shown.is_empty() { + String::new() + } else { + format!("{} of {}", selected + 1, shown.len()) + }, + ), + ], + w - 1, + )); + let cols = columns(w); + let per_item = if cols.single { 1 } else { 2 }; + visible = (h.saturating_sub(rows.len() + 1) / per_item).max(1); + scroll = scroll.min(shown.len().saturating_sub(visible)); + if selected < scroll { + scroll = selected; + } else if selected >= scroll + visible { + scroll = selected - visible + 1; + } + for (i, d) in shown.iter().enumerate().skip(scroll).take(visible) { + if rows.len() >= h.saturating_sub(1) { + break; + } + let here = i == selected; + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let meta = &d["meta"]; + let state = text(d, "state"); + let colour = state_colour(&state, &p); + let mark = if LIVE.contains(&state.as_str()) { + SPINNER[tick % SPINNER.len()] + } else if state == "READY" { + '●' + } else if state == "ERROR" { + '✖' + } else { + '○' + }; + let subject = text(meta, "githubCommitMessage"); + let subject = subject.lines().next().unwrap_or("").to_string(); + let c = |colour: &str| format!("{}{}", tint, colour); + let mut line = vec![ + ( + c(colour), + format!( + "{}{} {:<9}", + if here { "▸" } else { " " }, + mark, + titled(&state) + ), + ), + (c(&p.txt), tc::pad(&text(d, "name"), cols.project)), + (c(&p.dim), dur(build_seconds(d))), + (c(&p.dim), format!(" {:>4}", age(ms_at(d, "created")))), + ]; + if text(d, "target") == "production" { + line.push((c(&p.prod), " PROD".into())); + } else if cols.detail { + line.push((c(&p.dim), " prev".into())); + } + if cols.detail { + let sha = text(meta, "githubCommitSha"); + line.push(( + c(&p.sha), + format!(" {}", &sha[..sha.len().min(7)]), + )); + line.push(( + c(&p.branch), + format!(" {}", tc::pad(&text(meta, "githubCommitRef"), cols.branch)), + )); + } + if cols.single { + line.push((c(if here { &p.txt } else { &p.msg }), format!(" {}", subject))); + } + if here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + if !cols.single && rows.len() < h.saturating_sub(1) { + rows.push(tc::seg( + &[ + (&c(if here { &p.txt } else { &p.msg }), format!(" {}", subject)), + (&tint, if here { " ".repeat(w) } else { String::new() }), + ], + w - 1, + )); + } + } + if shown.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " (nothing matches the current filter)".into())], + w - 1, + )); + } + + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " select".into())], + vec![ + (p.accent.as_str(), "→/↵".into()), + (p.dim.as_str(), " details".into()), + ], + vec![(p.dim.as_str(), format!("[s]tate {}", FILTERS[filter]))], + vec![(p.dim.as_str(), "[/]filter".into())], + vec![(p.dim.as_str(), "[r]efresh".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + let footer: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + rows.truncate(h.saturating_sub(footer.len())); + while rows.len() < h.saturating_sub(footer.len()) { + rows.push(String::new()); + } + rows.extend(footer); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(250)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The line that says why a build failed is the one worth finding, and + /// it arrives on stderr among dozens of stdout lines that all look the + /// same. Vercel marks them; this checks we keep the mark. + #[test] + fn the_build_log_picks_stderr_out_of_stdout() { + let p = palette(); + let detail = serde_json::json!({ + "_events": [ + {"type": "stdout", "text": "Running build in Washington, D.C."}, + {"type": "stdout", "text": "Build machine configuration: 4 cores"}, + {"type": "stderr", "text": "Error: No Next.js version detected."}, + {"type": "stdout", "text": ""}, + ] + }); + let rows = build_log(&detail, 90, &p); + let joined = rows.join("\n"); + assert!(joined.contains("BUILD LOG"), "no heading"); + assert!(joined.contains("Error: No Next.js version"), "the failing line is missing"); + assert!(joined.contains("Running build in"), "the ordinary lines are missing"); + + let of = |needle: &str| { + rows.iter() + .find(|r| r.contains(needle)) + .unwrap_or_else(|| panic!("{:?} not drawn", needle)) + }; + assert!( + of("Error: No Next.js").starts_with(p.error.as_str()), + "stderr was not marked" + ); + assert!( + of("Running build in").starts_with(p.dim.as_str()), + "stdout was marked as an error" + ); + // A blank line from the build is not a row on the screen. + assert_eq!(joined.matches(" \n").count(), 0); + // The count in the heading is the lines the build produced, blank + // ones included - it is what the API returned, not what we drew. + assert!(of("BUILD LOG").contains("4 lines"), "{}", of("BUILD LOG")); + } + + #[test] + fn a_log_that_would_not_load_says_so_rather_than_vanishing() { + let p = palette(); + let detail = serde_json::json!({ "_events": { "_error": "curl exited 22" } }); + let rows = build_log(&detail, 90, &p); + let joined = rows.join("\n"); + assert!(joined.contains("BUILD LOG"), "the section disappeared"); + assert!(joined.contains("curl exited 22"), "the reason disappeared"); + // And a deployment with no events at all draws nothing, rather than + // an empty heading promising a log that is not coming. + assert!(build_log(&serde_json::json!({}), 90, &p).is_empty()); + assert!(build_log(&serde_json::json!({"_events": []}), 90, &p).is_empty()); + } + + + #[test] + fn a_token_prefers_the_config_then_the_environment() { + let cfg: serde_json::Value = + serde_json::from_str(r#"{"token": "from-config", "token_env": "NOPE_TOKEN"}"#).unwrap(); + assert_eq!(token(&cfg), ("from-config".to_string(), "config")); + // An empty token in the config is not a token. + let bare: serde_json::Value = serde_json::from_str(r#"{"token": ""}"#).unwrap(); + assert_eq!(token(&bare).1, "missing"); + } + + #[test] + fn a_build_time_covers_the_one_still_running() { + // Finished: the gap between the two stamps. + let done: serde_json::Value = + serde_json::from_str(r#"{"state": "READY", "buildingAt": 1000, "ready": 46000}"#) + .unwrap(); + assert_eq!(build_seconds(&done), Some(45.0)); + // Never started: nothing to report rather than zero. + let queued: serde_json::Value = serde_json::from_str(r#"{"state": "QUEUED"}"#).unwrap(); + assert_eq!(build_seconds(&queued), None); + // Finished but with no start stamp is also nothing, not a huge number. + let odd: serde_json::Value = + serde_json::from_str(r#"{"state": "READY", "ready": 46000}"#).unwrap(); + assert_eq!(build_seconds(&odd), None); + } + + #[test] + fn a_duration_keeps_its_column_width() { + assert_eq!(dur(None), " -- "); + assert_eq!(dur(Some(45.0)), " 45s"); + assert_eq!(dur(Some(45.0)).chars().count(), 6); + assert_eq!(dur(None).chars().count(), 6); + // Past a minute it changes shape, as the Python does. + assert_eq!(dur(Some(125.0)), "2m05s"); + } + + #[test] + fn the_copy_sheet_lists_only_what_exists() { + let dep: serde_json::Value = serde_json::from_str( + r#"{"inspectorUrl": "https://vercel.com/x", "url": "abc.vercel.app", + "meta": {"githubCommitSha": "0123456789", "githubOrg": "o", + "githubRepo": "r", "githubPrId": "7"}}"#, + ) + .unwrap(); + let items = copy_items(&dep, None); + let labels: Vec<&str> = items.iter().map(|(l, _)| l.as_str()).collect(); + assert_eq!( + labels, + vec![ + "Deployment dashboard", + "Commit preview", + "Pull request", + "Commit SHA" + ] + ); + // A pull request needs all three parts, not just the number. + assert_eq!(items[2].1, "https://github.com/o/r/pull/7"); + // No branch alias, so no branch preview - an empty row would be a + // key that copies nothing. + assert!(!labels.contains(&"Branch preview")); + } + + #[test] + fn width_buys_content_rather_than_padding() { + assert!(!columns(60).detail); + assert!(columns(66).detail); + assert!(!columns(100).single); + assert!(columns(110).single); + // The project column grows in steps and the branch scales, but + // neither runs away with a very wide pane. + assert_eq!(columns(70).project, 12); + assert_eq!(columns(200).project, 20); + assert_eq!(columns(200).branch, 34); + assert_eq!(columns(40).branch, 12); + } + + #[test] + fn a_state_reads_as_a_word() { + assert_eq!(titled("READY"), "Ready"); + assert_eq!(titled("INITIALIZING"), "Initializing"); + assert_eq!(titled(""), ""); + } + + #[test] + fn a_page_of_teams_hands_back_the_cursor_for_the_next_one() { + // The shape Vercel answers with. Reading only `teams` and stopping + // is how a token that can see thirty teams got deployments for the + // first twenty, on a board describing itself as covering them all. + let page = serde_json::json!({ + "teams": [{"id": "team_one"}, {"id": "team_two"}], + "pagination": {"count": 2, "next": 1588720733602i64, "prev": 0} + }); + let (ids, next) = teams_page(&page); + assert_eq!(ids, vec!["team_one".to_string(), "team_two".to_string()]); + assert_eq!(next, Some(1588720733602)); + + // The last page says so with a null cursor, and that is the only + // thing that means the walk is done. + let last = serde_json::json!({ + "teams": [{"id": "team_three"}], + "pagination": {"count": 1, "next": serde_json::Value::Null} + }); + assert_eq!(teams_page(&last).1, None); + + // A quoted cursor is still a cursor: read as absent it would stop + // the walk one page in and look like a smaller account. + let quoted = serde_json::json!({ + "teams": [], "pagination": {"next": "1588720733602"} + }); + assert_eq!(teams_page("ed).1, Some(1588720733602)); + + // An answer with neither in it is an empty page and a finished walk, + // not a panic. + assert_eq!(teams_page(&serde_json::json!({})), (Vec::new(), None)); + } + + #[test] + fn every_page_of_teams_is_asked_for() { + // Three pages, and the second and third are only reached by handing + // the cursor back. The first version of this stopped after page one + // and never requested a deployment for the teams below the fold. + let mut asked: Vec<Option<i64>> = Vec::new(); + let (ids, stopped) = walk_teams(|until| { + asked.push(until); + Ok(match until { + None => serde_json::json!({ + "teams": [{"id": "team_a"}, {"id": "team_b"}], + "pagination": {"next": 300} + }), + // The boundary team comes back on both sides of a timestamp + // cursor; a second copy would be a second scope, fetched and + // listed twice. + Some(300) => serde_json::json!({ + "teams": [{"id": "team_b"}, {"id": "team_c"}], + "pagination": {"next": 200} + }), + _ => serde_json::json!({ + "teams": [{"id": "team_d"}], + "pagination": {"next": serde_json::Value::Null} + }), + }) + }); + assert_eq!(asked, vec![None, Some(300), Some(200)]); + assert_eq!(ids, ["team_a", "team_b", "team_c", "team_d"]); + assert_eq!(stopped, None, "a complete walk has nothing to explain"); + + // A page that fails halfway leaves a list that is not the whole + // list, and says so - the board is missing a team either way, and + // the difference is whether the screen admits it. + let (some, stopped) = walk_teams(|until| match until { + None => Ok(serde_json::json!({ + "teams": [{"id": "team_a"}], "pagination": {"next": 300} + })), + _ => Err("curl exited 28".into()), + }); + assert_eq!(some, ["team_a"]); + let said = stopped.expect("a half-read list has to say so"); + assert!(said.contains("curl exited 28"), "{}", said); + + // Nothing at all still starts the widget on the personal scope, and + // still explains why that is all there is. + let (none, stopped) = walk_teams(|_| Err("HTTP 403".into())); + assert!(none.is_empty()); + assert!(stopped.unwrap_or_default().contains("HTTP 403")); + + // A cursor that never moves is a walk that would never end. It stops, + // and it does not pretend the list is complete. + let mut rounds = 0; + let (ids, stopped) = walk_teams(|_| { + rounds += 1; + Ok(serde_json::json!({ + "teams": [{"id": "team_a"}], "pagination": {"next": 300} + })) + }); + assert_eq!(ids, ["team_a"]); + assert!(rounds <= TEAM_PAGES, "the walk ran {} times", rounds); + assert!(stopped.is_some(), "a walk that gave up has to say so"); + } +} diff --git a/widgets/src/bin/deployments_help.txt b/widgets/src/bin/deployments_help.txt new file mode 100644 index 0000000..cfa3df2 --- /dev/null +++ b/widgets/src/bin/deployments_help.txt @@ -0,0 +1,27 @@ +Vercel deployments, live. + +Shows deployment activity over time, build-duration trend, and the most recent +deployments with their state, project, branch, commit and build time. + + deployments [-n SECONDS] [-t TEAM_ID] [project ...] + +Polls every 15s by default (-n changes it, minimum 5s). One request per team +per poll, so the default is 4 polls/min — modest against the API's limits. + +Keys while running: up/down (also PgUp/PgDn, Home/End) move the selection, +→ or Enter opens a full detail view for the selected deployment - state and +failure reason, timings, regions, commit, and everything worth copying on +number keys - r refreshes +now, s cycles the state filter (all / failed / production), / filters by text +against the project, branch, commit subject and deployment id, q quits. + +Copying uses OSC 52, so the terminal you are sitting at performs it and the +text reaches your local clipboard even over SSH. If your terminal or +multiplexer blocks OSC 52, the sheet still shows each URL in full for mouse +selection. + +Credentials: `deployments.token` in config.json, or $VERCEL_TOKEN. Create one +at Account Settings -> Tokens. The Vercel CLI's own session is deliberately not +used - it expires within hours and only the CLI can refresh it, so anything +reading it goes dark overnight. The token is read locally and never printed. `vercel ls --all --format json` is an equivalent data source +but spawns a Node process per refresh, so this queries the REST API directly. diff --git a/widgets/src/bin/github.rs b/widgets/src/bin/github.rs new file mode 100644 index 0000000..5c68a9e --- /dev/null +++ b/widgets/src/bin/github.rs @@ -0,0 +1,2090 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Pull request throughput across every account you can see. +//! +//! A port of github.py. Counted with aliased searches rather than by +//! reading nodes: a search connection returns at most 100 nodes a page, so +//! a busy fortnight lost everything past the hundredth record, while an +//! issueCount is exact at any volume and costs one rate-limit point per +//! request however many are packed into it. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::{Duration as Days, NaiveDate, Utc}; +use toys_core as tc; + +/// How many of the longest-open PRs an account's own screen names. +/// How long ago an ISO-8601 stamp was, coarse on purpose: "47d" answers the +/// question a queue raises and a timestamp does not. +fn age_since(iso: &str) -> String { + let Ok(at) = chrono::DateTime::parse_from_rfc3339(iso) else { + return String::new(); + }; + let secs = (Utc::now() - at.with_timezone(&Utc)).num_seconds().max(0); + if secs >= 86400 { + format!("{}d", secs / 86400) + } else { + format!("{}h", secs / 3600) + } +} + +const OLDEST_WANTED: usize = 10; + +const API: &str = "https://api.github.com/graphql"; +const WINDOWS: &[i64] = &[7, 14, 30, 60, 90]; +/// A full year, like the calendar on github.com. +const CONTRIB_WEEKS: i64 = 52; +/// Two searches a day; the alias ceiling sits between 60 and 90. +const DAY_CHUNK: usize = 20; +/// Trailing days to always refetch: today is still running, and the search +/// index lags a little behind a merge. +const FRESH_DAYS: usize = 2; +const SETTLE_FRAMES: usize = 8; +const SPARK: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +const WEEKDAYS: &[&str] = &["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const GHOST: (u8, u8, u8) = (96, 106, 124); +const PR_RGB: (u8, u8, u8) = (180, 160, 255); +const OK_RGB: (u8, u8, u8) = (90, 240, 160); + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// A GitHub token from config.json or the environment. +/// +/// Deliberately not shelled out to the `gh` CLI: this widget talks to the +/// API directly and should not require another program to be installed, +/// logged in and current just to read a number. +fn token(cfg: &serde_json::Value) -> (String, &'static str) { + let from_config = tc::cfg_str(cfg, "token", ""); + if !from_config.is_empty() { + return (from_config, "config"); + } + let name = tc::cfg_str(cfg, "token_env", "GITHUB_TOKEN"); + let name = if name.is_empty() { "GITHUB_TOKEN".into() } else { name }; + match std::env::var(&name) { + Ok(value) if !value.is_empty() => (value, "env"), + _ => (String::new(), "missing"), + } +} + +/// The open PRs that have been open longest, for one account. +/// +/// The board and the row are built entirely from `issueCount` aggregates, +/// which are exact at any volume and cost one rate-limit point per request +/// rather than per alias. That is the right shape for counting and useless +/// for naming: a queue of six hundred says nothing about which of them has +/// been sitting there since June. +/// +/// So this asks for nodes, and only when an account's own screen is opened - +/// five of them, oldest first. One request, on demand, for the question the +/// aggregates cannot answer. +fn fetch_oldest(acc: &str, viewer: &str, tok: &str, scopes: &Arc<Mutex<Scopes>>) -> serde_json::Value { + let q = scope_of(acc, viewer); + let query = format!( + r#"{{ + search(query:"{q} is:pr is:open sort:created-asc", type:ISSUE, first:{n}) {{ + nodes {{ + ... on PullRequest {{ + number + title + url + createdAt + isDraft + repository {{ name }} + }} + }} + }} +}}"#, + q = q, + n = OLDEST_WANTED + ); + match graphql(&query, tok, scopes) { + Ok(v) => v["data"]["search"]["nodes"].clone(), + Err(e) => serde_json::json!({ "_error": e }), + } +} + +/// One account in full. +/// +/// Everything here is already on the board somewhere - the row it came from +/// carries all of it - but the row has one line and has to choose. Open +/// splits into what is waiting on a reviewer and what is still a draft; +/// merged splits into what landed and what was closed unmerged; and the +/// flow chart, which the board draws once for every account added together, +/// is drawn here for this one alone. That last is the reason to open it: a +/// queue growing in one account is invisible in a total that six others +/// are also feeding. +/// +/// No new request. The figures were fetched for the row. +fn account_detail( + a: &Account, + oldest: Option<&serde_json::Value>, + pick: usize, + w: usize, + h: usize, + p: &Palette, +) -> (Vec<String>, Option<usize>) { + // Where the cursor over the oldest list ended up, so the caller can + // scroll to it. The caller cannot work it out: how far down the page + // that list starts depends on how many fields this account had. + let mut cursor: Option<usize> = None; + let mut rows = vec![tc::title(&a.account, w, &p.accent)]; + let label_w = 16usize; + let mut field = |name: &str, value: String, aside: String, colour: &str| { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", tc::pad(name, label_w))), + (colour, format!("{:>7}", value)), + (p.dim.as_str(), format!(" {}", aside)), + ], + w - 1, + )); + }; + + let waiting = a.review; + let drafts = a.draft; + field( + "open", + a.open.to_string(), + match (waiting, drafts) { + (0, 0) => String::new(), + (r, 0) => format!("{} awaiting review", r), + (0, d) => format!("{} draft", d), + (r, d) => format!("{} awaiting review · {} draft", r, d), + }, + p.pr.as_str(), + ); + field("issues", a.issues.to_string(), String::new(), p.txt.as_str()); + + // Opened against merged over the same window is the question the row + // cannot answer: a queue of six hundred is a different thing depending + // on whether it grew by forty this week or shrank by ten. + let window_days = a.hist_window.unwrap_or(a.window).max(1); + let opened_total: i64 = a.opened_hist.values().sum(); + let merged_total: i64 = a.hist.values().sum(); + let net = opened_total - merged_total; + field( + "opened", + opened_total.to_string(), + format!("in {}d", window_days), + p.pr.as_str(), + ); + + let window = format!("in {}d", a.window); + field( + "merged", + a.merged.to_string(), + match a.dropped { + 0 => window.clone(), + n => format!("{} · {} closed unmerged", window, n), + }, + p.ok.as_str(), + ); + if opened_total > 0 || merged_total > 0 { + field( + "net", + format!("{:+}", net), + match net { + 0 => "the queue held level".to_string(), + n if n > 0 => format!("the queue grew by {}", n), + n => format!("the queue shrank by {}", -n), + }, + if net > 0 { p.warn.as_str() } else { p.ok.as_str() }, + ); + } + if merged_total > 0 { + let per_day = merged_total as f64 / window_days as f64; + field( + "merged/day", + format!("{:.1}", per_day), + // How long the open queue would take at the rate actually + // observed. A number people usually estimate and get wrong. + if per_day > 0.0 && a.open > 0 { + format!("{:.0}d of open PRs at that rate", a.open as f64 / per_day) + } else { + String::new() + }, + p.txt.as_str(), + ); + let busiest = a.hist.iter().max_by_key(|(_, n)| **n); + if let Some((day, n)) = busiest { + if *n > 0 { + field("busiest day", n.to_string(), day.clone(), p.dim.as_str()); + } + } + let idle = window_days as usize - a.hist.values().filter(|n| **n > 0).count(); + if idle > 0 { + field( + "days with none", + idle.to_string(), + format!("of {}", window_days), + p.dim.as_str(), + ); + } + } + if let Some(rate) = a.rate { + let bar = tc::meter(rate / 100.0, w.saturating_sub(label_w + 22).clamp(6, 24)); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", tc::pad("merge rate", label_w))), + (p.ok.as_str(), format!("{:>6.0}%", rate)), + (p.dim.as_str(), " ".into()), + (p.ok.as_str(), bar), + ], + w - 1, + )); + } + + // The same bar the board draws for everything at once, for this account + // alone: a queue is a different shape depending on whether it is waiting + // on reviewers or waiting on authors. + if a.open > 0 && h.saturating_sub(rows.len()) >= 4 { + let ready = (a.open - a.draft - a.review).max(0); + let legend: Vec<(&str, i64, &str)> = [ + ("awaiting review", a.review, p.warn.as_str()), + ("ready to merge", ready, p.ok.as_str()), + ("draft", a.draft, p.dim.as_str()), + ] + .into_iter() + .filter(|x| x.1 > 0) + .collect(); + if !legend.is_empty() { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPEN PR STATE ── ".into()), + (p.dim.as_str(), "any age".into()), + ], + w - 1, + )); + let parts: Vec<(f64, String)> = legend + .iter() + .map(|(_, n, c)| (*n as f64 / a.open as f64, c.to_string())) + .collect(); + let bar = tc::stacked_bar(&parts, w.saturating_sub(3).max(10)); + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, txt) in &bar { + line.push((colour.as_str(), txt.clone())); + } + rows.push(tc::seg(&line, w - 1)); + let mut key: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, count, colour) in &legend { + key.push((colour, "▇ ".into())); + key.push((p.txt.as_str(), (*label).into())); + key.push(( + p.dim.as_str(), + format!(" {} ({:.0}%) ", count, 100.0 * *count as f64 / a.open as f64), + )); + } + rows.push(tc::seg(&key, w - 1)); + } + } + + // The ones that have been open longest, which no count can name. + if h.saturating_sub(rows.len()) >= 4 { + rows.push(String::new()); + match oldest { + None => { + rows.push(tc::seg( + &[(p.lbl.as_str(), " ── OLDEST OPEN ──".into())], + w - 1, + )); + rows.push(tc::seg(&[(p.dim.as_str(), " asking…".into())], w - 1)); + } + Some(v) if !v["_error"].is_null() => { + rows.push(tc::seg( + &[(p.lbl.as_str(), " ── OLDEST OPEN ──".into())], + w - 1, + )); + rows.push(tc::seg( + &[(p.dim.as_str(), format!(" {}", v["_error"].as_str().unwrap_or("")))], + w - 1, + )); + } + Some(v) => { + let nodes = v.as_array().cloned().unwrap_or_default(); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OLDEST OPEN ── ".into()), + ( + p.dim.as_str(), + if nodes.is_empty() { + "nothing open".to_string() + } else { + format!("{} longest waiting", nodes.len()) + }, + ), + ], + w - 1, + )); + for (i, node) in nodes.iter().enumerate() { + let here = i == pick.min(nodes.len().saturating_sub(1)); + if here { + cursor = Some(rows.len()); + } + let age = age_since(node["createdAt"].as_str().unwrap_or("")); + let repo = node["repository"]["name"].as_str().unwrap_or("").to_string(); + let num = node["number"].as_i64().unwrap_or(0); + let draft = node["isDraft"].as_bool().unwrap_or(false); + let head = format!(" {:>5} #{:<6}", age, num); + let room = w.saturating_sub(head.chars().count() + repo.chars().count() + 6); + let title = node["title"].as_str().unwrap_or("").to_string(); + let title: String = if title.chars().count() > room { + format!("{}…", title.chars().take(room.saturating_sub(1)).collect::<String>()) + } else { + title + }; + let tint = if here { tc::bg(38, 56, 76) } else { String::new() }; + let c = |colour: &str| { + // Same shape as the other widgets that do this, so one rule + // reads them all: a guard per colour, each reaching its own + // lighter twin. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + rows.push(tc::seg( + &[ + ( + &c(if here { p.accent.as_str() } else { p.dim.as_str() }), + if here { " ▸".into() } else { " ".to_string() }, + ), + ( + &c(if draft { p.dim.as_str() } else { p.warn.as_str() }), + head.trim_start().to_string(), + ), + (&c(p.dim.as_str()), format!(" {} ", repo)), + (&c(p.txt.as_str()), title), + (&tint, if here { " ".repeat(w) } else { String::new() }), + ], + w - 1, + )); + } + } + } + } + + // The same chart the board draws for every account at once, for this + // one on its own. + let want = a.hist_window.unwrap_or(a.window).max(1); + let base = today(); + let mut days: Vec<String> = (0..want) + .rev() + .map(|n| (base - Days::days(n)).format("%Y-%m-%d").to_string()) + .collect(); + let avail = w.saturating_sub(3).max(10); + if days.len() > avail { + days = days[days.len() - avail..].to_vec(); + } + let slot = (avail / days.len().max(1)).max(1); + let gap = if slot >= 3 { 1 } else { 0 }; + let barw = slot - gap; + let spread = |per_day: &[f64]| -> Vec<f64> { + let mut cols = Vec::new(); + for (n, v) in per_day.iter().enumerate() { + cols.extend(std::iter::repeat_n(*v, barw)); + if gap > 0 && n + 1 < per_day.len() { + cols.extend(std::iter::repeat_n(0.0, gap)); + } + } + cols + }; + let opened: Vec<f64> = days + .iter() + .map(|d| a.opened_hist.get(d).copied().unwrap_or(0) as f64) + .collect(); + let merged: Vec<f64> = days + .iter() + .map(|d| a.hist.get(d).copied().unwrap_or(0) as f64) + .collect(); + let (up, down) = (spread(&opened), spread(&merged)); + // One scale both ways, or the comparison lies. + let hi = up + .iter() + .chain(down.iter()) + .cloned() + .fold(0.0f64, f64::max) + .max(1.0); + if h.saturating_sub(rows.len()) >= 10 && !up.is_empty() { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── PR FLOW ── ".into()), + (p.dim.as_str(), format!("{}d · ", days.len())), + (p.pr.as_str(), format!("▲ {} opened", opened.iter().sum::<f64>() as i64)), + (p.dim.as_str(), " · ".into()), + (p.ok.as_str(), format!("▼ {} merged", merged.iter().sum::<f64>() as i64)), + (p.dim.as_str(), format!(" peak {}/day", hi as i64)), + ], + w - 1, + )); + for line in tc::vbars(&up.iter().map(|v| (*v, p.pr.clone())).collect::<Vec<_>>(), 3, hi) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + rows.push(tc::seg( + &[(tc::RST, " ".into()), (p.grid.as_str(), "─".repeat(up.len()))], + w - 1, + )); + for line in + tc::vbars_down(&down.iter().map(|v| (*v, p.ok.clone())).collect::<Vec<_>>(), 3, hi) + { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + let left = format!("{}d ago", days.len()); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", left)), + ( + p.dim.as_str(), + format!( + "{:>width$}", + "today", + width = up.len().saturating_sub(left.chars().count() + 1) + ), + ), + ], + w - 1, + )); + } + (rows, cursor) +} + +/// What the token is allowed to see, read off the response headers. +#[derive(Default, Clone)] +struct Scopes { + seen: bool, + have: Vec<String>, +} + +fn graphql( + query: &str, + tok: &str, + scopes: &Arc<Mutex<Scopes>>, +) -> Result<serde_json::Value, String> { + let body = serde_json::json!({ "query": query }).to_string(); + let (text, headers) = tc::post_json( + API, + &[ + ("Authorization", &format!("Bearer {}", tok)), + ("Content-Type", "application/json"), + ("User-Agent", "terminal-toys"), + ], + &body, + 30, + )?; + for (name, value) in &headers { + // Absent on fine-grained tokens, which is itself information: a + // header that never arrives means the check cannot be made. + if name == "x-oauth-scopes" { + if let Ok(mut g) = scopes.lock() { + g.seen = true; + g.have = value + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + } + let data: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; + // GraphQL answers 200 with an `errors` array, so a successful request is + // not a successful query. Read past it and every alias the query asked + // for is missing, which `count_at` turns into zeros - and those zeros go + // into the day cache as historical fact, so the affected days are never + // retried and the chart shows a quiet week that never happened. linear + // and pr both refuse this in the same place; github did not. + if let Some(first) = data["errors"].as_array().and_then(|a| a.first()) { + return Err(first["message"].as_str().unwrap_or("").chars().take(80).collect()); + } + Ok(data) +} + +/// Flag a token that will undercount rather than fail. +/// +/// A classic token without `repo` still searches happily - it just returns +/// public results only, so every figure comes back smaller with nothing to +/// say it did. Without `read:org` the account list comes back short the +/// same way. Both are worse than an error, so name them. +fn scope_warning(scopes: &Scopes) -> String { + if !scopes.seen { + return String::new(); + } + let missing: Vec<&str> = ["repo", "read:org"] + .into_iter() + .filter(|want| !scopes.have.iter().any(|had| had == want)) + .collect(); + if missing.is_empty() { + return String::new(); + } + let why = if missing.contains(&"repo") { + "private repos are not counted" + } else { + "orgs cannot be discovered" + }; + format!("token lacks {} - {}", missing.join(" and "), why) +} + +/// The search qualifier that limits results to a single account. +fn scope_of(acc: &str, viewer: &str) -> String { + if acc == "@me" { + format!("user:{}", viewer) + } else { + format!("org:{}", acc) + } +} + +/// Exact per-day PR counts for one account. +fn build_day_query(q: &str, dates: &[String]) -> String { + let mut parts = vec!["{".to_string()]; + for (n, day) in dates.iter().enumerate() { + parts.push(format!( + "\n m{n}: search(query:\"{q} is:pr is:merged merged:{d}\", type:ISSUE) {{ issueCount }}\ + \n c{n}: search(query:\"{q} is:pr created:{d}\", type:ISSUE) {{ issueCount }}", + n = n, + q = q, + d = day + )); + } + parts.push("\n}".into()); + parts.join("") +} + +/// Metrics for one account in one request. +/// +/// Six aliased searches per account keeps each request within GitHub's +/// complexity limit - asking for seven accounts at once returned HTTP 502 - +/// while still being far fewer round trips than one query per metric. +fn build_query(acc: &str, days: i64, viewer: &str) -> String { + // N days *ending today*, so this spans exactly the dates the per-day + // charts plot - `days` rather than `days - 1` would cover one day more + // and quietly disagree with the chart drawn directly beneath it. + let since = (today() - Days::days(days - 1)).format("%Y-%m-%d").to_string(); + let q = scope_of(acc, viewer); + format!( + r#"{{ + o0_open: search(query:"{q} is:pr is:open", type:ISSUE) {{ issueCount }} + o0_draft: search(query:"{q} is:pr is:open draft:true", type:ISSUE) {{ issueCount }} + o0_review: search(query:"{q} is:pr is:open review:required", type:ISSUE) {{ issueCount }} + o0_merged: search(query:"{q} is:pr is:merged merged:>={s}", type:ISSUE) {{ issueCount }} + o0_dropped: search(query:"{q} is:pr is:unmerged is:closed closed:>={s}", type:ISSUE) {{ issueCount }} + o0_issues: search(query:"{q} is:issue is:open", type:ISSUE) {{ issueCount }} + rateLimit {{ remaining limit }} +}}"#, + q = q, + s = since + ) +} + +/// GitHub's own contribution calendar - the green squares. +/// +/// contributionsCollection is per-viewer rather than per-org, so this is +/// your activity across everything, which is what the calendar means on +/// github.com. +fn contribution_query(weeks: i64) -> String { + let since = (Utc::now() - Days::weeks(weeks)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + format!( + r#"{{ viewer {{ contributionsCollection(from:"{}") {{ + contributionCalendar {{ totalContributions + weeks {{ contributionDays {{ date contributionCount weekday }} }} }} }} }} }}"#, + since + ) +} + +/// The calendar day this machine is having, which is what a chart headed +/// "today" has to agree with. +fn today() -> NaiveDate { + chrono::Local::now().date_naive() +} + +fn count_at(d: &serde_json::Value, key: &str) -> i64 { + d[key]["issueCount"].as_i64().unwrap_or(0) +} + +/// One account's row, and the two per-day series behind its chart. +#[derive(Clone, Default)] +struct Account { + key: String, + account: String, + is_me: bool, + /// Which window these figures cover, so a half-updated board cannot sum + /// two windows together. + window: i64, + open: i64, + draft: i64, + review: i64, + issues: i64, + merged: i64, + dropped: i64, + rate: Option<f64>, + hist: HashMap<String, i64>, + opened_hist: HashMap<String, i64>, + hist_window: Option<i64>, +} + +#[derive(Default)] +struct State { + stats: Vec<Account>, + accounts: Vec<String>, + rate: Option<(i64, i64)>, + calendar: Option<serde_json::Value>, + err: String, + fetched: f64, + days: i64, + /// Set by [r]: drop the day cache and refetch even past days. + bust: bool, +} + +fn ago(t: f64) -> String { + if t <= 0.0 { + return "--".into(); + } + let s = now() - t; + if s < 90.0 { + format!("{}s", s as i64) + } else if s < 5400.0 { + format!("{}m", (s / 60.0) as i64) + } else { + format!("{}h", (s / 3600.0) as i64) + } +} + +/// Streaks and totals behind the contribution calendar. +/// +/// A streak is consecutive days carrying at least one contribution, counted +/// the way github.com does it: a day that has scored nothing *so far* does +/// not break the current streak, because it is not over yet. +struct CalendarStats { + today: i64, + current: i64, + longest: i64, + active: usize, + span: usize, + busiest: (String, i64), + weekday: (&'static str, i64), +} + +fn calendar_stats(weeks: &serde_json::Value) -> Option<CalendarStats> { + let mut days: Vec<(String, i64, usize)> = weeks + .as_array()? + .iter() + .flat_map(|wk| wk["contributionDays"].as_array().into_iter().flatten()) + .map(|d| { + ( + d["date"].as_str().unwrap_or("").to_string(), + d["contributionCount"].as_i64().unwrap_or(0), + d["weekday"].as_u64().unwrap_or(0) as usize, + ) + }) + .collect(); + if days.is_empty() { + return None; + } + days.sort(); + let today_key = today().format("%Y-%m-%d").to_string(); + + let (mut longest, mut run) = (0i64, 0i64); + for (_, count, _) in &days { + run = if *count > 0 { run + 1 } else { 0 }; + longest = longest.max(run); + } + + let mut done: Vec<&(String, i64, usize)> = + days.iter().filter(|(d, _, _)| *d <= today_key).collect(); + if done.last().is_some_and(|(_, c, _)| *c == 0) { + done.pop(); // today is still in progress + } + let mut current = 0i64; + for (_, count, _) in done.iter().rev() { + if *count == 0 { + break; + } + current += 1; + } + + let mut per_weekday: HashMap<usize, i64> = HashMap::new(); + for (_, count, wd) in &days { + *per_weekday.entry(*wd).or_insert(0) += count; + } + let top_wd = per_weekday + .iter() + .max_by_key(|(wd, n)| (**n, std::cmp::Reverse(**wd))) + .map(|(wd, _)| *wd) + .unwrap_or(0); + let busiest = days + .iter() + .max_by_key(|(_, c, _)| *c) + .map(|(d, c, _)| (d.clone(), *c)) + .unwrap_or_default(); + Some(CalendarStats { + today: days + .iter() + .find(|(d, _, _)| *d == today_key) + .map(|(_, c, _)| *c) + .unwrap_or(0), + current, + longest, + active: days.iter().filter(|(_, c, _)| *c > 0).count(), + span: days.len(), + busiest, + weekday: ( + WEEKDAYS[top_wd.min(6)], + per_weekday.get(&top_wd).copied().unwrap_or(0), + ), + }) +} + +/// The calendar as seven rows of one cell per week. +fn heatmap(weeks: &serde_json::Value, w: usize) -> (Vec<String>, i64, i64) { + const LEVELS: &[char] = &[' ', '░', '▒', '▓', '█']; + let all: Vec<&serde_json::Value> = weeks.as_array().map(|a| a.iter().collect()).unwrap_or_default(); + let counts: Vec<i64> = all + .iter() + .flat_map(|wk| wk["contributionDays"].as_array().into_iter().flatten()) + .map(|d| d["contributionCount"].as_i64().unwrap_or(0)) + .collect(); + let peak = counts.iter().copied().max().unwrap_or(0); + let total: i64 = counts.iter().sum(); + let cols = all.len().min(w.saturating_sub(8)).max(4).min(all.len().max(4)); + let shown = &all[all.len().saturating_sub(cols)..]; + let mut grid = vec![vec![' '; shown.len()]; 7]; + for (x, wk) in shown.iter().enumerate() { + for d in wk["contributionDays"].as_array().into_iter().flatten() { + let n = d["contributionCount"].as_i64().unwrap_or(0); + let wd = (d["weekday"].as_u64().unwrap_or(0) as usize).min(6); + let level = if n == 0 { + 0 + } else { + (1 + (n as f64 / peak.max(1) as f64 * 3.99) as usize).min(4) + }; + grid[wd][x] = LEVELS[level]; + } + } + ( + grid.into_iter().map(|row| row.into_iter().collect()).collect(), + peak, + total, + ) +} + +struct Palette { + ok: String, + warn: String, + bad: String, + dim: String, + /// A colour to draw over the selected-row tint. + /// + /// `dim` is 3.81 against `bg(38, 56, 76)`, under the 4.5 CLAUDE.md asks for + /// against the tint as well as the background. This is the same grey lifted + /// until it clears - 4.94 - and it is used *only* where a tint is on, so an + /// untinted row is exactly the colour it always was. Not quite the same as + /// "unselected": herdr-panes tints a blocked or done row whether or not it + /// is selected, and those get the lighter colours too. + /// + /// The substitution happens inside the closure that composes the tint, not + /// at each call site. Seventeen sites were counted when this was found and + /// there were twenty-three by the time it was fixed; more than half of them + /// reach `dim` through a condition that has nothing to do with selection - + /// `if count > 0 { loud } else { dim }` - and a zero count is the normal + /// state, so those are the common case rather than the rare one. Anyone + /// fixing this a call site at a time would fix the obvious half. + dim_lit: String, + grid: String, + txt: String, + lbl: String, + accent: String, + pr: String, +} + +fn palette() -> Palette { + Palette { + ok: tc::rgb(90, 240, 160), + warn: tc::rgb(255, 200, 90), + bad: tc::rgb(255, 100, 110), + dim: tc::rgb(127, 147, 172), + dim_lit: tc::rgb(140, 170, 195), + grid: tc::rgb(60, 78, 98), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + accent: tc::rgb(150, 210, 255), + pr: tc::rgb(180, 160, 255), + } +} + +/// One page of the orgs the viewer belongs to, from `after` onwards. +/// +/// A hundred at a time, and the caller follows `endCursor` until GitHub +/// says there is no next page. +fn orgs_query(after: Option<&str>) -> String { + let at = match after { + Some(c) => format!(", after: {}", serde_json::Value::String(c.to_string())), + None => String::new(), + }; + format!( + "{{ viewer {{ login organizations(first: 100{}) {{ pageInfo {{ hasNextPage endCursor }} nodes {{ login }} }} }} }}", + at + ) +} + +#[allow(clippy::too_many_arguments)] +fn one_pass( + tok: &str, + source: &str, + viewer: &mut String, + day_cache: &mut HashMap<String, HashMap<String, (i64, i64)>>, + state: &Arc<Mutex<State>>, + scopes: &Arc<Mutex<Scopes>>, +) -> Result<(), String> { + if viewer.is_empty() { + let who = graphql("{ viewer { login } }", tok, scopes)?; + // An errors envelope has no data.viewer, and `unwrap_or("")` turned + // that into an empty login the pass then carried on with - every + // query after it silently scoped to nobody. Unreadable rendered as + // empty is the one failure this collection exists to avoid, so the + // pass stops and says why, which is what github.py does. + *viewer = match who["data"]["viewer"]["login"].as_str() { + Some(login) if !login.is_empty() => login.to_string(), + _ => { + let why = who["errors"][0]["message"] + .as_str() + .unwrap_or("no viewer login in the response"); + // By characters, not bytes: a message with any multibyte + // character crossing byte fifty would panic the slice, and + // this one comes from a server. + return Err(format!( + "who am I: {}", + why.chars().take(50).collect::<String>() + )); + } + }; + } + let mut accounts = state.lock().map(|g| g.accounts.clone()).unwrap_or_default(); + if accounts.is_empty() { + // Every org you belong to, plus your own account - and *every* is + // what docs/github.md promises for an empty `accounts`. One page of + // twenty kept that promise only for people who belong to fewer than + // twenty; past that the extra orgs were not undercounted, they were + // never asked about, and every headline on the board was a total + // over an account list that nothing on screen said was short. So + // the cursor is followed to the end. + let mut cursor: Option<String> = None; + loop { + let d = graphql(&orgs_query(cursor.as_deref()), tok, scopes)?; + let conn = &d["data"]["viewer"]["organizations"]; + accounts.extend( + conn["nodes"] + .as_array() + .into_iter() + .flatten() + .map(|o| o["login"].as_str().unwrap_or("").to_string()) + .filter(|s| !s.is_empty()), + ); + let next = conn["pageInfo"]["endCursor"] + .as_str() + .unwrap_or("") + .to_string(); + // This runs on the poller thread: a cursor that stops advancing + // has to end the loop rather than spin it. + if !conn["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false) + || next.is_empty() + || Some(&next) == cursor.as_ref() + { + break; + } + cursor = Some(next); + } + accounts.push("@me".into()); + if let Ok(mut g) = state.lock() { + g.accounts = accounts.clone(); + } + } + + // The calendar is best-effort: it is decoration beside the counts, and a + // fine-grained token that cannot read it should not blank the board. + if let Ok(cal) = graphql(&contribution_query(CONTRIB_WEEKS), tok, scopes) { + let found = cal["data"]["viewer"]["contributionsCollection"]["contributionCalendar"].clone(); + if !found.is_null() { + if let Ok(mut g) = state.lock() { + g.calendar = Some(found); + } + } + } + + let (days_now, bust) = match state.lock() { + Ok(mut g) => { + let out = (g.days, g.bust); + g.bust = false; + out + } + Err(_) => return Err("state lock poisoned".into()), + }; + // Start the pass from what is already on screen, keyed by account, so + // rows are replaced in place as each lands instead of the table + // emptying and refilling every pass. + let mut by_acc: HashMap<String, Account> = state + .lock() + .map(|g| g.stats.iter().map(|a| (a.key.clone(), a.clone())).collect()) + .unwrap_or_default(); + let base = today(); + let dates: Vec<String> = (0..days_now) + .rev() + .map(|k| (base - Days::days(k)).format("%Y-%m-%d").to_string()) + .collect(); + let keep_from = (base - Days::days(WINDOWS[WINDOWS.len() - 1] - 1)) + .format("%Y-%m-%d") + .to_string(); + let mut failed: Vec<String> = Vec::new(); + let mut rate: Option<(i64, i64)> = None; + + // Aggregates first, for every account, before any per-day work. One + // request each, so the headline is live in seconds; the day charts below + // can cost fifty requests on a cold 90d window and would otherwise hold + // the whole board grey for minutes. + for acc in &accounts { + let data = match graphql(&build_query(acc, days_now, viewer), tok, scopes) { + Ok(d) => d, + Err(e) => { + // Fifty characters, which is what the branch below used to + // take before `graphql` started refusing an errors payload + // itself and made that branch unreachable. + failed.push(format!("{} ({})", acc, e.chars().take(50).collect::<String>())); + continue; + } + }; + let d = &data["data"]; + if let Some(limit) = d["rateLimit"]["limit"].as_i64() { + rate = Some((d["rateLimit"]["remaining"].as_i64().unwrap_or(0), limit)); + } + let (merged, dropped) = (count_at(d, "o0_merged"), count_at(d, "o0_dropped")); + let prev = by_acc.get(acc).cloned().unwrap_or_default(); + by_acc.insert( + acc.clone(), + Account { + key: acc.clone(), + account: if acc == "@me" { viewer.clone() } else { acc.clone() }, + is_me: acc == "@me", + window: days_now, + open: count_at(d, "o0_open"), + draft: count_at(d, "o0_draft"), + review: count_at(d, "o0_review"), + issues: count_at(d, "o0_issues"), + merged, + dropped, + rate: if merged + dropped > 0 { + Some(100.0 * merged as f64 / (merged + dropped) as f64) + } else { + None + }, + hist: prev.hist, + opened_hist: prev.opened_hist, + hist_window: prev.hist_window, + }, + ); + publish(state, &accounts, &by_acc, rate); + } + + // Then the per-day counts. A past day cannot change - a PR merged on the + // 3rd stays merged on the 3rd - so only days never seen before, plus the + // trailing few, cost a request. Widening the window therefore buys only + // the days it adds; narrowing is free. + for acc in &accounts { + if !by_acc.contains_key(acc) { + continue; + } + let cache = day_cache.entry(acc.clone()).or_default(); + if bust { + cache.clear(); + } + let fresh: Vec<&String> = dates.iter().rev().take(FRESH_DAYS).collect(); + let want: Vec<String> = dates + .iter() + .filter(|x| !cache.contains_key(*x) || fresh.contains(x)) + .cloned() + .collect(); + for chunk in want.chunks(DAY_CHUNK) { + let dd = match graphql(&build_day_query(&scope_of(acc, viewer), chunk), tok, scopes) { + Ok(d) => d["data"].clone(), + Err(_) => continue, + }; + for (n, day) in chunk.iter().enumerate() { + cache.insert( + day.clone(), + ( + count_at(&dd, &format!("m{}", n)), + count_at(&dd, &format!("c{}", n)), + ), + ); + } + } + cache.retain(|day, _| *day >= keep_from); // older than any window + if !dates.iter().all(|x| cache.contains_key(x)) { + continue; // a chunk failed; leave it + } + if let Some(row) = by_acc.get_mut(acc) { + row.hist = dates.iter().map(|x| (x.clone(), cache[x].0)).collect(); + row.opened_hist = dates.iter().map(|x| (x.clone(), cache[x].1)).collect(); + row.hist_window = Some(days_now); + } + publish(state, &accounts, &by_acc, rate); + } + + if let Ok(mut g) = state.lock() { + // With nothing else to report, surface a token sitting in a file + // other users on the box can read. + g.err = if !failed.is_empty() { + format!("could not read: {}", failed.join(", ")) + } else { + let warn = scopes.lock().map(|s| scope_warning(&s)).unwrap_or_default(); + if !warn.is_empty() { + warn + } else if source == "config" { + tc::config_token_warning().unwrap_or_default() + } else { + String::new() + } + }; + } + Ok(()) +} + +fn publish( + state: &Arc<Mutex<State>>, + accounts: &[String], + by_acc: &HashMap<String, Account>, + rate: Option<(i64, i64)>, +) { + if let Ok(mut g) = state.lock() { + g.stats = accounts + .iter() + .filter_map(|a| by_acc.get(a).cloned()) + .collect(); + if rate.is_some() { + g.rate = rate; + } + g.fetched = now(); + } +} + +fn main() { + tc::maybe_help(include_str!("github_help.txt")); + let cfg = tc::load_config("github"); + let mut refresh = tc::cfg_f64(&cfg, "refresh", 120.0); + let configured: Vec<String> = tc::cfg_strings(&cfg, "accounts", &[]); + let start_window = (tc::cfg_f64(&cfg, "window_days", 14.0) as i64).max(1); + + let args: Vec<String> = std::env::args().skip(1).collect(); + let mut named: Vec<String> = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-n" | "--refresh" if i + 1 < args.len() => { + refresh = args[i + 1].parse::<f64>().unwrap_or(120.0).max(30.0); + i += 2; + } + other if !other.starts_with('-') => { + named.push(other.to_string()); + i += 1; + } + _ => i += 1, + } + } + refresh = tc::poll_secs(refresh, 120.0).max(30.0); + + let absent = tc::missing(&["curl"]); + if !absent.is_empty() { + tc::cannot_start( + "github ops", + &absent, + &[ + "Everything here comes from GitHub's GraphQL API, and curl is", + "how this reaches it - the same way the other widgets reach", + "ss, ping and tailscale.", + "", + "The token is passed to curl on its standard input rather than", + "in its arguments, because /proc/<pid>/cmdline is readable by", + "every user on the machine.", + ], + "apt install curl", + ); + return; + } + + let p = palette(); + let state = Arc::new(Mutex::new(State { + accounts: if named.is_empty() { configured } else { named }, + days: start_window, + ..Default::default() + })); + let scopes = Arc::new(Mutex::new(Scopes::default())); + let wake = Arc::new((Mutex::new(false), Condvar::new())); + let (tok, source) = token(&cfg); + // The poller thread takes ownership of it; the render loop needs it too, + // for the one on-demand request an account's own screen makes. + let ui_tok = tok.clone(); + let ui_scopes = Arc::clone(&scopes); + let env_name = { + let name = tc::cfg_str(&cfg, "token_env", "GITHUB_TOKEN"); + if name.is_empty() { "GITHUB_TOKEN".to_string() } else { name } + }; + + let poller = Arc::clone(&state); + let poller_wake = Arc::clone(&wake); + let poller_scopes = Arc::clone(&scopes); + std::thread::spawn(move || { + let mut viewer = String::new(); + let mut day_cache: HashMap<String, HashMap<String, (i64, i64)>> = HashMap::new(); + loop { + if tok.is_empty() { + if let Ok(mut g) = poller.lock() { + g.err = format!( + "no token: set github.token in config.json or ${} (needs repo + read:org)", + env_name + ); + } + } else if let Err(said) = one_pass( + &tok, + source, + &mut viewer, + &mut day_cache, + &poller, + &poller_scopes, + ) { + if let Ok(mut g) = poller.lock() { + g.err = said; + } + } + let (lock, cond) = &*poller_wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(refresh)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + *asked = false; + } + }); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let (mut selected, mut tick) = (0usize, 0usize); + // One account on its own screen, and how far down it is scrolled. + let (mut detail, mut dscroll) = (false, 0usize); + // Which of the oldest PRs the cursor is on, and what [c] last said. + let mut osel = 0usize; + let (mut note, mut note_at) = (String::new(), 0.0f64); + // The longest-open PRs per account, fetched when that account's screen + // is opened and kept after. + let oldest: Arc<Mutex<HashMap<String, serde_json::Value>>> = Arc::new(Mutex::new(HashMap::new())); + let asking: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new())); + let mut settle_t = 0usize; + let mut settle_from: Option<(Vec<f64>, Vec<f64>)> = None; + + loop { + tick += 1; + for key in keyboard.poll() { + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "r" | "R" => { + // A manual refresh re-reads even past days. + if let Ok(mut g) = state.lock() { + g.bust = true; + } + let (lock, cond) = &*wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + } + "w" | "W" => { + if let Ok(mut g) = state.lock() { + g.days = tc::cycle(WINDOWS, g.days); + } + let (lock, cond) = &*wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + } + "right" | "enter" => { + detail = true; + dscroll = 0; + } + "left" | "esc" if detail => detail = false, + "up" if detail => osel = osel.saturating_sub(1), + "down" if detail => osel = osel.saturating_add(1), + "c" | "C" if detail => { + // The account under the cursor, read from the shared + // state rather than the render's copy - the keys are + // handled before the frame is built. + let key = state + .lock() + .ok() + .and_then(|g| { + g.stats + .get(selected.min(g.stats.len().saturating_sub(1))) + .map(|a| a.key.clone()) + }) + .unwrap_or_default(); + let url = oldest + .lock() + .ok() + .and_then(|g| g.get(&key).cloned()) + .and_then(|v| v.as_array().cloned()) + .and_then(|n| n.get(osel).cloned()) + .map(|n| n["url"].as_str().unwrap_or("").to_string()) + .unwrap_or_default(); + if !url.is_empty() { + note = if tc::clipboard(&url) { + format!("✓ copied {}", url) + } else { + format!("no clipboard: {}", url) + }; + note_at = now(); + } + } + "pgup" if detail => { + let page = tc::size().1.saturating_sub(3).max(1); + dscroll = dscroll.saturating_sub(page); + } + "pgdn" if detail => { + let page = tc::size().1.saturating_sub(3).max(1); + dscroll = dscroll.saturating_add(page); + } + "home" if detail => dscroll = 0, + "end" if detail => dscroll = usize::MAX, + "up" => selected = selected.saturating_sub(1), + "down" => selected += 1, + _ => {} + } + } + + let (w, h) = tc::size(); + let (mut stats, rate, err, fetched, calendar, want) = match state.lock() { + Ok(g) => ( + g.stats.clone(), + g.rate, + g.err.clone(), + g.fetched, + g.calendar.clone(), + g.days, + ), + Err(_) => return, + }; + // Busiest first: open PRs decide it, and merged-in-window breaks ties + // so an idle backlog ranks below an account of the same size that is + // actually moving. Name last, to keep the order steady frame to frame. + stats.sort_by(|a, b| { + b.open + .cmp(&a.open) + .then(b.merged.cmp(&a.merged)) + .then(a.account.to_lowercase().cmp(&b.account.to_lowercase())) + }); + // Windowed figures are stale until every account has reported for the + // window now selected. The charts are tracked apart from the headline + // because their data costs far more requests and lands well after it. + let stale = stats.is_empty() || stats.iter().any(|x| x.window != want); + let chart_stale = stats.is_empty() || stats.iter().any(|x| x.hist_window != Some(want)); + if !stats.is_empty() && selected >= stats.len() { + selected = stats.len() - 1; + } + + let mut rows = vec![tc::title("github ops", w, &p.pr)]; + let mut head = vec![ + ( + p.dim.as_str(), + format!( + " {} account{}", + stats.len(), + if stats.len() == 1 { "" } else { "s" } + ), + ), + (p.dim.as_str(), format!(" updated {} ago", ago(fetched))), + ]; + if let Some((left, limit)) = rate { + head.push(( + if left > 1000 { p.ok.as_str() } else { p.warn.as_str() }, + format!(" {}/{} api", left, limit), + )); + } + rows.push(tc::seg(&head, w - 1)); + if !err.is_empty() { + rows.push(tc::seg(&[(p.bad.as_str(), format!(" ! {}", err))], w - 1)); + } + if stats.is_empty() { + rows.push(tc::seg(&[(p.dim.as_str(), " collecting…".into())], w - 1)); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(400)); + continue; + } + + let sum = |f: fn(&Account) -> i64| -> i64 { stats.iter().map(f).sum() }; + let (open, draft, review, issues, merged, dropped) = ( + sum(|a| a.open), + sum(|a| a.draft), + sum(|a| a.review), + sum(|a| a.issues), + sum(|a| a.merged), + sum(|a| a.dropped), + ); + let rate_pct = if merged + dropped > 0 { + Some(100.0 * merged as f64 / (merged + dropped) as f64) + } else { + None + }; + // What is outstanding right now leads the board: it is the question + // asked most often, and the only section that is not windowed. + if open > 0 { + let ready = (open - draft - review).max(0); + let legend: Vec<(&str, i64, &str)> = [ + ("awaiting review", review, p.warn.as_str()), + ("ready to merge", ready, p.ok.as_str()), + ("draft", draft, p.dim.as_str()), + ] + .into_iter() + .filter(|x| x.1 > 0) + .collect(); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPEN PR STATE ── ".into()), + (p.pr.as_str(), format!("{}", open)), + (p.dim.as_str(), " PRs · ".into()), + (p.warn.as_str(), format!("{}", issues)), + (p.dim.as_str(), " issues open (any age)".into()), + ], + w - 1, + )); + let parts: Vec<(f64, String)> = legend + .iter() + .map(|(_, n, c)| (*n as f64 / open as f64, c.to_string())) + .collect(); + let bar = tc::stacked_bar(&parts, w.saturating_sub(3).max(10)); + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, txt) in &bar { + line.push((colour.as_str(), txt.clone())); + } + rows.push(tc::seg(&line, w - 1)); + let mut key: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, count, colour) in &legend { + key.push((colour, "▇ ".into())); + key.push((p.txt.as_str(), (*label).into())); + key.push(( + p.dim.as_str(), + format!(" {} ({:.0}%) ", count, 100.0 * *count as f64 / open as f64), + )); + } + rows.push(tc::seg(&key, w - 1)); + } + + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── MERGE RATE ── ".into()), + (p.dim.as_str(), format!("last {} days", want)), + ], + w - 1, + )); + let bar_w = w.saturating_sub(34).max(10); + if stale { + let shimmer = tc::skeleton(bar_w, tick, 7); + let mut line: Vec<(&str, String)> = vec![(p.dim.as_str(), format!(" {:<5}", "···"))]; + for (colour, txt) in &shimmer { + line.push((colour.as_str(), txt.clone())); + } + line.push((p.dim.as_str(), format!(" loading {}d…", want))); + rows.push(tc::seg(&line, w - 1)); + } else { + let hot = match rate_pct { + Some(v) => tc::heat(v / 100.0), + None => p.dim.clone(), + }; + rows.push(tc::seg( + &[ + ( + hot.as_str(), + format!( + " {:<5}", + match rate_pct { + Some(v) => format!("{:.0}%", v), + None => "--".into(), + } + ), + ), + (hot.as_str(), tc::meter(rate_pct.unwrap_or(0.0) / 100.0, bar_w)), + (p.ok.as_str(), format!(" {} merged", merged)), + (p.dim.as_str(), " / ".into()), + (p.bad.as_str(), format!("{} dropped", dropped)), + ], + w - 1, + )); + } + + let mut merged_all: HashMap<String, i64> = HashMap::new(); + let mut opened_all: HashMap<String, i64> = HashMap::new(); + for st in &stats { + if st.hist_window != Some(want) { + continue; // covers a different window; adding it lies + } + for (day, n) in &st.hist { + *merged_all.entry(day.clone()).or_insert(0) += n; + } + for (day, n) in &st.opened_hist { + *opened_all.entry(day.clone()).or_insert(0) += n; + } + } + let base = today(); + let mut days: Vec<String> = (0..want) + .rev() + .map(|n| (base - Days::days(n)).format("%Y-%m-%d").to_string()) + .collect(); + // The chart always fills the pane. Where there is room to spare a day + // takes several columns; where there is not, the oldest days are + // cropped rather than the whole chart squeezed into a corner. + let avail = w.saturating_sub(3).max(10); + if days.len() > avail { + days = days[days.len() - avail..].to_vec(); + } + let slot = (avail / days.len()).max(1); + let gap = if slot >= 3 { 1 } else { 0 }; + let barw = slot - gap; + let spread = |per_day: &[f64]| -> Vec<f64> { + let mut cols = Vec::new(); + for (n, v) in per_day.iter().enumerate() { + cols.extend(std::iter::repeat_n(*v, barw)); + if gap > 0 && n + 1 < per_day.len() { + cols.extend(std::iter::repeat_n(0.0, gap)); + } + } + cols + }; + let opened_day: Vec<f64> = days + .iter() + .map(|d| opened_all.get(d).copied().unwrap_or(0) as f64) + .collect(); + let merged_day: Vec<f64> = days + .iter() + .map(|d| merged_all.get(d).copied().unwrap_or(0) as f64) + .collect(); + let (up, down) = (spread(&opened_day), spread(&merged_day)); + let chart_cols = up.len(); + // One scale both ways, or the comparison lies. + let span_hi = up + .iter() + .chain(down.iter()) + .cloned() + .fold(0.0f64, f64::max) + .max(1.0); + rows.push(String::new()); + if chart_stale { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── PR FLOW ── ".into()), + (p.dim.as_str(), format!("counting {}d…", want)), + ], + w - 1, + )); + } else { + // Totals come from the days themselves: a day spans several + // columns now, so summing the columns would multiply by bar width. + let span = if days.len() < want as usize { + format!("{}d of {}d", days.len(), want) + } else { + format!("{}d", days.len()) + }; + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── PR FLOW ── ".into()), + (p.dim.as_str(), format!("{} · ", span)), + ( + p.pr.as_str(), + format!("▲ {} opened", opened_day.iter().sum::<f64>() as i64), + ), + (p.dim.as_str(), " · ".into()), + ( + p.ok.as_str(), + format!("▼ {} merged", merged_day.iter().sum::<f64>() as i64), + ), + (p.dim.as_str(), format!(" peak {}/day", span_hi as i64)), + ], + w - 1, + )); + } + // While the figures are still arriving the bars bounce like a level + // meter in pale versions of their own colours, then settle onto the + // real values rather than cutting to them. Three rows each side, + // always - trimming the unused half would make the chart change + // height at the end of the animation, which is exactly when it + // should be still. + let (hu, hd, cu, cd) = if chart_stale { + // Dance per day, then widen: bouncing each column on its own + // would show a twelve-column day as twelve separate thin bars. + let hu = spread(&tc::dance(days.len(), tick, 0.0)); + let hd = spread(&tc::dance(days.len(), tick, 2.1)); + settle_from = Some((hu.clone(), hd.clone())); + settle_t = 0; + ( + hu, + hd, + tc::mix(GHOST, PR_RGB, 0.45), + tc::mix(GHOST, OK_RGB, 0.45), + ) + } else { + let real_u: Vec<f64> = up.iter().map(|v| v / span_hi).collect(); + let real_d: Vec<f64> = down.iter().map(|v| v / span_hi).collect(); + match &settle_from { + Some((fu, fd)) if settle_t < SETTLE_FRAMES && fu.len() == chart_cols => { + settle_t += 1; + let q = settle_t as f64 / SETTLE_FRAMES as f64; + let q = q * q * (3.0 - 2.0 * q); // ease in and out + ( + fu.iter().zip(&real_u).map(|(a, b)| a + (b - a) * q).collect(), + fd.iter().zip(&real_d).map(|(a, b)| a + (b - a) * q).collect(), + tc::mix(GHOST, PR_RGB, 0.45 + 0.55 * q), + tc::mix(GHOST, OK_RGB, 0.45 + 0.55 * q), + ) + } + _ => (real_u, real_d, p.pr.clone(), p.ok.clone()), + } + }; + for line in tc::vbars(&hu.iter().map(|v| (*v, cu.clone())).collect::<Vec<_>>(), 3, 1.0) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + // An explicit baseline: without it the two series abut and the eye + // cannot tell which row the bars grow from. + rows.push(tc::seg( + &[(tc::RST, " ".into()), (p.grid.as_str(), "─".repeat(chart_cols))], + w - 1, + )); + for line in tc::vbars_down(&hd.iter().map(|v| (*v, cd.clone())).collect::<Vec<_>>(), 3, 1.0) + { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + let left = format!("{}d ago", days.len()); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", left)), + ( + p.dim.as_str(), + " ".repeat(chart_cols.saturating_sub(left.len() + 5).max(1)), + ), + (p.dim.as_str(), "today".into()), + ], + w - 1, + )); + rows.push(String::new()); + + // The account table earns the remaining height; the calendar keeps + // its place only where the pane is tall enough for both. + if let Some(cal) = calendar.as_ref().filter(|_| h > 38) { + let (grid, peak, total) = heatmap(&cal["weeks"], w); + let total_c = cal["totalContributions"].as_i64().unwrap_or(total); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── CONTRIBUTIONS ── ".into()), + ( + p.dim.as_str(), + format!("{} in {} weeks, peak {}/day", total_c, CONTRIB_WEEKS, peak), + ), + ], + w - 1, + )); + for (r, line) in grid.iter().enumerate() { + // Rows are GitHub's own weekday index, where 0 is Sunday, so + // the labels come off the same constant rather than a + // hand-written tuple. Written Monday-first they sat one row + // early and put today under yesterday's name. + let label = if r == 1 || r == 3 || r == 5 { WEEKDAYS[r] } else { "" }; + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {:<4}", label)), + (p.ok.as_str(), line.clone()), + ], + w - 1, + )); + } + if let Some(cs) = calendar_stats(&cal["weeks"]) { + let cells: Vec<(String, String, &str)> = vec![ + ( + "current streak".into(), + format!("{} days", cs.current), + if cs.current > 0 { p.ok.as_str() } else { p.dim.as_str() }, + ), + ("longest streak".into(), format!("{} days", cs.longest), p.txt.as_str()), + ( + "today".into(), + format!("{}", cs.today), + if cs.today > 0 { p.ok.as_str() } else { p.dim.as_str() }, + ), + ( + "active days".into(), + format!( + "{} of {} ({:.0}%)", + cs.active, + cs.span, + 100.0 * cs.active as f64 / cs.span as f64 + ), + p.txt.as_str(), + ), + ( + "busiest".into(), + format!("{} ({})", cs.busiest.0, cs.busiest.1), + p.txt.as_str(), + ), + ( + "most on".into(), + format!("{} ({})", cs.weekday.0, cs.weekday.1), + p.txt.as_str(), + ), + ]; + // As many columns as the width honestly allows, never fewer + // than one - the labels are what make these readable. + let ncols = if w >= 86 { + 3 + } else if w >= 58 { + 2 + } else { + 1 + }; + let cw = (w - 2) / ncols; + for chunk in cells.chunks(ncols) { + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, value, colour) in chunk { + let used = label.len() + 1 + value.len(); + line.push((p.dim.as_str(), format!("{} ", label))); + line.push((colour, value.clone())); + line.push((tc::RST, " ".repeat(cw.saturating_sub(used).max(2)))); + } + rows.push(tc::seg(&line, w - 1)); + } + } + rows.push(String::new()); + } + + // Scroll rather than truncate: the selection has to stay on screen, + // or the arrows move something invisible. + let room = h.saturating_sub(5 + rows.len()).max(1); + let first = if stats.len() > room { + selected.saturating_sub(room / 2).min(stats.len() - room) + } else { + 0 + }; + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── BY ACCOUNT ──".into()), + ( + p.dim.as_str(), + if stats.len() > room { + format!( + " {}-{} of {}", + first + 1, + (first + room).min(stats.len()), + stats.len() + ) + } else { + String::new() + }, + ), + ], + w - 1, + )); + let wide = w >= 62; + let bar_cols = w.saturating_sub(64).max(4); + // No separators between these fields: the row emits its widths + // back-to-back, so a space here drifts the header one column per + // field. MRG takes seven, since "MRG60D" is six characters and would + // sit flush against REVW in every window but the seven-day one. + let mut head = format!( + " {:<20}{:>5}{:>5}{:>7}{:>6}", + "ACCOUNT", + "OPEN", + "REVW", + format!("MRG{}D", want), + "RATE" + ); + let spark_days: Vec<String> = (0..(want as usize).min(bar_cols) as i64) + .rev() + .map(|n| (base - Days::days(n)).format("%Y-%m-%d").to_string()) + .collect(); + if wide { + head += &format!("{:>7}", "ISSUES"); + // Each row is scaled to its own busiest day, so say what the + // reader may do with it - read the shape - rather than naming + // the mechanism. Pick the longest label that fits rather than + // clipping one: a truncated hint is worse than a shorter one. + let label = [ + "MERGED/DAY · SHAPE ONLY, NOT TO SCALE", + "MERGED/DAY · SHAPE ONLY", + "MERGED/DAY (shape)", + "MERGED/DAY", + "", + ] + .into_iter() + .find(|l| l.len() <= bar_cols) + .unwrap_or(""); + if !label.is_empty() { + head += &format!(" {}", label); + } + } + rows.push(tc::seg(&[(p.dim.as_str(), tc::pad(&head, w - 1))], w - 1)); + for (i, s) in stats.iter().enumerate().skip(first).take(room) { + let here = i == selected; + let tint = if here { tc::bg(38, 56, 76) } else { String::new() }; + let c = |colour: &str| { + // Same shape as the other widgets that do this, so one rule + // reads them all: a guard per colour, each reaching its own + // lighter twin. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + // This row's own staleness: accounts land one at a time, so an + // account already refetched for the new window shows real numbers + // while the ones behind it still shimmer. + let old = s.window != want; + let hot = match s.rate { + Some(r) if !old => tc::heat(r / 100.0), + _ => p.dim.clone(), + }; + let mut line = vec![ + ( + c(if here { &p.accent } else { &p.txt }), + format!( + "{}{}", + if here { "▸" } else { " " }, + tc::pad( + &format!("{}{}", s.account, if s.is_me { " (you)" } else { "" }), + 20 + ) + ), + ), + (c(&p.pr), format!("{:>5}", s.open)), + ( + c(if s.review > 0 { &p.warn } else { &p.dim }), + format!("{:>5}", s.review), + ), + ( + c(if old { &p.dim } else { &p.ok }), + format!("{:>7}", if old { "···".to_string() } else { s.merged.to_string() }), + ), + ( + c(&hot), + format!( + "{:>6}", + if old { + "···".to_string() + } else { + match s.rate { + Some(r) => format!("{:.0}%", r), + None => "--".into(), + } + } + ), + ), + ]; + if wide { + line.push((c(&p.dim), format!("{:>7}", s.issues))); + // Each account's own merged-per-day. The columns carry + // totals but no shape, and a fortnight of nothing ending in + // a spike reads very differently from a steady trickle. + if s.hist_window != Some(want) { + line.push((c(&p.grid), format!(" {}", "·".repeat(spark_days.len())))); + } else { + let top = s.hist.values().copied().max().unwrap_or(0); + let mut marks = String::new(); + for d in &spark_days { + let v = s.hist.get(d).copied().unwrap_or(0); + marks.push(if v > 0 && top > 0 { + SPARK[(((v as f64 / top as f64) * 7.99) as usize).min(7)] + } else { + ' ' + }); + } + line.push((c(&p.ok), format!(" {}", marks))); + } + } + if here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + + // One account in full, opened from the row it belongs to. + if detail { + if let Some(a) = stats.get(selected.min(stats.len().saturating_sub(1))) { + // One request, on opening, for the question the aggregates + // cannot answer. Held per account so leaving and coming back + // does not ask again. + let key = a.key.clone(); + let held = oldest.lock().ok().and_then(|g| g.get(&key).cloned()); + if held.is_none() { + let start = asking + .lock() + .map(|mut g| g.insert(key.clone())) + .unwrap_or(false); + if start { + let (oldest, asking) = (Arc::clone(&oldest), Arc::clone(&asking)); + // key is "@me" or the org; account is the bare + // login, which is what scope_of wants for "@me". + let (acc, viewer, tok, scopes) = + (a.key.clone(), a.account.clone(), ui_tok.clone(), Arc::clone(&ui_scopes)); + std::thread::spawn(move || { + let got = fetch_oldest(&acc, &viewer, &tok, &scopes); + if let Ok(mut g) = oldest.lock() { + g.insert(acc.clone(), got); + } + if let Ok(mut g) = asking.lock() { + g.remove(&acc); + } + }); + } + } + let nodes = held + .as_ref() + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(); + osel = osel.min(nodes.len().saturating_sub(1)); + let (body, cursor) = account_detail(a, held.as_ref(), osel, w, h, &p); + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![ + (p.accent.as_str(), "↑↓".into()), + (p.dim.as_str(), if nodes.is_empty() { " scroll" } else { " oldest" }.into()), + ], + vec![(p.dim.as_str(), "[c]opy url".into())], + vec![(p.dim.as_str(), "pgup/pgdn page".into())], + vec![ + (p.accent.as_str(), "←".into()), + (p.dim.as_str(), "/esc back".into()), + ], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + let foot: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + let room = h.saturating_sub(foot.len()).max(1); + // The page follows the cursor into the oldest list, the way + // netwatch's detail follows one into a section. Without it + // the row being selected is often off the bottom. + if let Some(at) = cursor { + if at < dscroll { + dscroll = at; + } else if at >= dscroll + room { + dscroll = at + 1 - room; + } + } + dscroll = dscroll.min(body.len().saturating_sub(room)); + let last = (dscroll + room).min(body.len()); + let mut out: Vec<String> = body[dscroll..last].to_vec(); + while out.len() < room { + out.push(String::new()); + } + if !note.is_empty() && now() - note_at < 6.0 { + if let Some(row) = out.last_mut() { + *row = tc::seg(&[(p.ok.as_str(), format!(" {}", note))], w - 1); + } + } + out.extend(foot); + tc::draw(&out, w, h); + std::thread::sleep(Duration::from_millis(300)); + continue; + } + detail = false; + } + + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " account".into())], + vec![ + (p.accent.as_str(), "→/↵".into()), + (p.dim.as_str(), " account".into()), + ], + vec![(p.dim.as_str(), "[w]indow".into())], + vec![(p.dim.as_str(), "[r]efresh".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + let footer: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + rows.truncate(h.saturating_sub(footer.len())); + while rows.len() < h.saturating_sub(footer.len()) { + rows.push(String::new()); + } + rows.extend(footer); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(300)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_token_that_will_undercount_says_so() { + // A classic token missing `repo` still searches - it just silently + // returns public results only, which is worse than an error. + let short = Scopes { + seen: true, + have: vec!["read:org".into()], + }; + assert!(scope_warning(&short).contains("private repos are not counted")); + let no_org = Scopes { + seen: true, + have: vec!["repo".into()], + }; + assert!(scope_warning(&no_org).contains("orgs cannot be discovered")); + let full = Scopes { + seen: true, + have: vec!["repo".into(), "read:org".into(), "gist".into()], + }; + assert_eq!(scope_warning(&full), ""); + // A fine-grained token sends no scope header at all, so there is + // nothing to check and nothing to claim. + assert_eq!(scope_warning(&Scopes::default()), ""); + } + + #[test] + fn org_discovery_follows_the_cursor() { + // The first page asks for no cursor at all, and for the page + // information that says whether there is another. + let first = orgs_query(None); + assert!(first.contains("organizations(first: 100)"), "{}", first); + assert!(first.contains("hasNextPage") && first.contains("endCursor")); + // A cursor is a string GitHub chose, so it goes in quoted rather + // than pasted: it has carried `=` and `==` for as long as it has + // been base64. + let next = orgs_query(Some("Y3Vyc29yOnYyOpHOAAQ=")); + assert!( + next.contains(r#"organizations(first: 100, after: "Y3Vyc29yOnYyOpHOAAQ=")"#), + "{}", + next + ); + } + + #[test] + fn an_account_scopes_its_own_search() { + assert_eq!(scope_of("acme", "wiiiimm"), "org:acme"); + // @me is the viewer, resolved once rather than sent literally. + assert_eq!(scope_of("@me", "wiiiimm"), "user:wiiiimm"); + } + + #[test] + fn the_window_ends_today_and_spans_exactly_its_days() { + // `days - 1` back from today, because the chart under it plots N + // days *including* today - one more would quietly disagree with it. + let q = build_query("acme", 7, "w"); + let since = (today() - Days::days(6)).format("%Y-%m-%d").to_string(); + assert!(q.contains(&format!("merged:>={}", since)), "{}", q); + assert!(q.contains("org:acme is:pr is:open")); + assert!(q.contains("rateLimit")); + } + + #[test] + fn a_day_query_asks_for_counts_not_records() { + // A search connection returns at most 100 nodes a page, so a busy + // fortnight lost everything past the hundredth record. issueCount is + // exact at any volume. + let dates = vec!["2026-08-01".to_string(), "2026-08-02".to_string()]; + let q = build_day_query("org:acme", &dates); + assert_eq!(q.matches("issueCount").count(), 4); + assert!(q.contains("m0:") && q.contains("c0:")); + assert!(q.contains("m1:") && q.contains("c1:")); + assert!(!q.contains("nodes")); + } + + #[test] + fn a_streak_is_not_broken_by_a_day_still_running() { + // Sunday through Saturday, with today scoring nothing yet. + let weeks: serde_json::Value = serde_json::from_str(&format!( + r#"[{{"contributionDays": [ + {{"date": "{}", "contributionCount": 3, "weekday": 0}}, + {{"date": "{}", "contributionCount": 5, "weekday": 1}}, + {{"date": "{}", "contributionCount": 0, "weekday": 2}}]}}]"#, + (today() - Days::days(2)).format("%Y-%m-%d"), + (today() - Days::days(1)).format("%Y-%m-%d"), + today().format("%Y-%m-%d"), + )) + .unwrap(); + let cs = calendar_stats(&weeks).expect("a calendar"); + // Two days behind it, and today's zero does not end the run. + assert_eq!(cs.current, 2); + assert_eq!(cs.longest, 2); + assert_eq!(cs.today, 0); + assert_eq!(cs.active, 2); + assert_eq!(cs.busiest.1, 5); + } + + #[test] + fn the_heatmap_is_seven_rows_whatever_the_data() { + let weeks: serde_json::Value = serde_json::from_str( + r#"[{"contributionDays": [ + {"date": "2026-08-16", "contributionCount": 0, "weekday": 0}, + {"date": "2026-08-17", "contributionCount": 9, "weekday": 1}]}]"#, + ) + .unwrap(); + let (grid, peak, total) = heatmap(&weeks, 80); + assert_eq!(grid.len(), 7); + assert_eq!(peak, 9); + assert_eq!(total, 9); + // Nothing on a day is a blank cell, not the lowest shade - the + // difference between "quiet" and "none" is the whole point. + assert_eq!(grid[0].chars().next(), Some(' ')); + assert_eq!(grid[1].chars().next(), Some('█')); + } +} diff --git a/widgets/src/bin/github_help.txt b/widgets/src/bin/github_help.txt new file mode 100644 index 0000000..204b3b0 --- /dev/null +++ b/widgets/src/bin/github_help.txt @@ -0,0 +1,25 @@ +GitHub delivery metrics across every org and account you can see. + +Open pull requests, how many are actually merging, review backlog and issue +counts - for one org, several, or your personal account alongside them. + + github [-n SECONDS] [account ...] + +Accounts are org logins, or @me for your own. With none given it uses +`github.accounts` from config, and failing that every org you belong to plus +your personal account. + +Open counts - PRs, issues, drafts, review backlog - are point-in-time totals of +whatever is open right now, at any age. Everything else - the merge rate, the +per-day charts and the per-account merged/rate columns - covers the merge +window, which is the N days ending today. + +Credentials: `github.token` in config.json, or $GITHUB_TOKEN. It must be a +*classic* token: a fine-grained one is limited to a single resource owner, so +it cannot span the orgs this board exists to compare. Two scopes - `repo` so search sees private +repositories, and `read:org` to enumerate your orgs. Missing either one does +not fail, it silently undercounts, so the granted scopes are checked and named. +The API is called directly, so the `gh` CLI is not required. + +Keys: up/down select an account, r refreshes now, w cycles the window +(7/14/30/60/90 days), q quits. diff --git a/widgets/src/bin/herdr-panes.rs b/widgets/src/bin/herdr-panes.rs new file mode 100644 index 0000000..834ca89 --- /dev/null +++ b/widgets/src/bin/herdr-panes.rs @@ -0,0 +1,1545 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Everything running in Herdr, across every workspace. +//! +//! A port of herdr-panes.py. A Herdr client rather than a general agent +//! monitor: the inventory and the lifecycle states come from the `herdr` +//! CLI, and any agent kind it recognises appears here with no change to +//! this file. + +use std::collections::HashMap; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use toys_core as tc; + +/// Worst first: the states that want a human are the reason to look. +const RANK: &[&str] = &["blocked", "done", "working", "idle", "unknown"]; +const SPINNER: &[char] = &['◐', '◓', '◑', '◒']; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +fn rank_of(state: &str) -> usize { + RANK.iter().position(|s| *s == state).unwrap_or(9) +} + +/// Seconds before a herdr command is given up on, from herdr-panes.py. +const RUN_TIMEOUT: u64 = 15; + +/// Run a herdr command for its effect; true when it succeeded. +/// +/// Bounded, because the socket on the other end can stop answering and +/// .output() would wait for it forever with the pane still drawing. +fn herdr_action(args: &[&str]) -> bool { + let mut argv = vec!["herdr"]; + argv.extend_from_slice(args); + tc::run(&argv, RUN_TIMEOUT).is_ok() +} + +/// The `result` object out of one herdr answer, or why there is none. +/// +/// Split from the running of the command, because the running is not where +/// the failure shows: herdr answers a request it cannot serve with an +/// `error` object and exit status 0, so a pane that does not exist and a +/// pane with nothing to say arrive as two commands that both succeeded. +/// They are told apart by shape here or they are not told apart at all. +fn result_of(text: &str) -> Result<serde_json::Value, String> { + let parsed: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("unreadable answer: {}", e))?; + if let Some(said) = parsed.get("error") { + let message = said["message"].as_str().unwrap_or("").trim(); + return Err(if message.is_empty() { + format!("herdr said {}", said) + } else { + message.to_string() + }); + } + match parsed.get("result") { + Some(serde_json::Value::Null) | None => Err("herdr answered with no result".into()), + Some(value) => Ok(value.clone()), + } +} + +/// Run a herdr command and hand back its `result`, or why there is none. +fn herdr_result(args: &[&str]) -> Result<serde_json::Value, String> { + let mut argv = vec!["herdr"]; + argv.extend_from_slice(args); + result_of(&tc::run(&argv, RUN_TIMEOUT)?) +} + +/// The same, for the callers that have nothing to do with the reason. +fn herdr(args: &[&str]) -> Option<serde_json::Value> { + herdr_result(args).ok() +} + +/// Keep the end of a path, marking the cut so it does not read as a name. +fn tail_path(path: &str, n: usize) -> String { + let chars: Vec<char> = path.chars().collect(); + if chars.len() <= n || n < 2 { + return path.to_string(); + } + format!("…{}", chars[chars.len() - (n - 1)..].iter().collect::<String>()) +} + +fn base_name(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_string() +} + +/// Readable name for what a pane is running. +/// +/// "python3" or "node" says nothing useful, so prefer the script they were +/// handed; otherwise fall back to the executable's own name. +fn command_label(argv: &[String], name: &str) -> String { + const RUNNERS: &[&str] = &[ + "python", "python3", "node", "ruby", "perl", "bun", "deno", "sh", "bash", "zsh", + ]; + let Some(first) = argv.first() else { + return if name.is_empty() { "?".into() } else { name.into() }; + }; + let head = base_name(first); + let stem = head.split('.').next().unwrap_or(&head); + if RUNNERS.contains(&stem) && argv.len() > 1 { + for token in &argv[1..] { + if !token.starts_with('-') { + return base_name(token); + } + } + } + head +} + +/// (cpu ticks used, resident bytes) out of one /proc/<pid>/stat line. +/// +/// Split from the read so it can be tested on a line rather than on a live +/// process. Its test used to re-implement this parse in the test body and +/// assert on the copy, which meant the shipped one was never run. +fn parse_proc_stat(text: &str) -> Option<(u64, u64)> { + // The command is in brackets and may itself contain spaces and brackets, + // so the split starts after the last one rather than at the second field. + let rest = text.rsplit_once(')')?.1; + let fields: Vec<&str> = rest.split_whitespace().collect(); + let utime: u64 = fields.get(11)?.parse().ok()?; + let stime: u64 = fields.get(12)?.parse().ok()?; + let rss: u64 = fields.get(21)?.parse().ok()?; + Some((utime + stime, rss * 4096)) +} + +/// (cpu ticks used, resident bytes) for a pid. +fn proc_stats(pid: i32) -> Option<(u64, u64)> { + let text = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?; + parse_proc_stat(&text) +} + +fn clock_ticks() -> f64 { + let hz = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if hz > 0 { + hz as f64 + } else { + 100.0 + } +} + +/// A recognised coding agent, and what its process is costing. +#[derive(Clone, Default)] +struct Agent { + name: String, + pane_id: String, + workspace_id: String, + state: String, + title: String, + cwd: String, + since: f64, + /// False when the state was already in place at the first poll, so the + /// duration is only a lower bound and says so. + exact: bool, + cpu: Option<f64>, + rss: Option<u64>, +} + +/// The slice of a variable-height list that fits `room` rows and holds the +/// cursor. +/// +/// The three lists here do not have one height between them: an agent takes +/// two rows, its second carrying the directory and the pane title, while a +/// process takes one. A window counted in *entries* therefore admits more +/// rows than the pane has, they are cut off the bottom, and the cursor goes +/// with them - which is the bug this is here to fix, arrived at the second +/// time rather than the first. +/// +/// `from` is where the window sat last frame. It is honoured when it can be, +/// so the view holds still while the cursor moves inside it, and moves by as +/// little as it takes when the cursor would leave. +fn window_over( + heights: &[usize], + at: usize, + room: usize, + from: usize, +) -> std::ops::Range<usize> { + let n = heights.len(); + if n == 0 || room == 0 { + return 0..0; + } + let at = at.min(n - 1); + // Never start below the cursor: reaching up moves the window to it. + let mut first = from.min(at); + loop { + let (mut used, mut end) = (0usize, first); + while end < n && used + heights[end] <= room { + used += heights[end]; + end += 1; + } + // A row taller than the whole pane still gets drawn, or the list + // would be empty and the cursor nowhere. + if end == first { + end = first + 1; + } + if at < end || first + 1 >= n { + return first..end; + } + first += 1; + } +} + +/// What a section heading adds when the window does not hold all of it. +/// +/// `first` is where the section starts in the flat list the three of them +/// make together, and `len` is how many rows it has. Nothing when the whole +/// section is on screen - a range on a section you can see all of is noise. +fn showing(window: &std::ops::Range<usize>, first: usize, len: usize) -> String { + if len == 0 { + return String::new(); + } + let from = window.start.max(first); + let to = window.end.min(first + len); + if from <= first && to >= first + len { + String::new() + } else if to <= from { + // Scrolled clean past it. Said out loud, because a heading with a + // count and no rows under it otherwise reads as a section that has + // failed to load rather than one you have scrolled away from. + " · none on screen".to_string() + } else { + format!(" · showing {}-{}", from - first + 1, to - first) + } +} + +/// What a pane with no agent in it is doing. +/// +/// Three states rather than a bool, because the third one is real: a pane +/// whose probe failed is neither running nor resting, and filing it as +/// either says something true has been established when nothing has. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Doing { + Running, + Prompt, + /// `pane process-info` did not answer. The default, because a pane + /// nobody has managed to look at has told us nothing. + #[default] + Unknown, +} + +/// Unreadable first, then what is running, then the prompts: the row that +/// wants looking at is the one where the widget cannot say. +fn doing_rank(doing: Doing) -> usize { + match doing { + Doing::Unknown => 0, + Doing::Running => 1, + Doing::Prompt => 2, + } +} + +/// A pane with no agent in it: running something, at a prompt, or unread. +#[derive(Clone, Default)] +struct Panel { + pane_id: String, + tab_id: String, + workspace_id: String, + command: String, + cwd: String, + doing: Doing, + /// Why the probe failed, when it did. Carried rather than counted: a + /// number of panes nobody could read is a smaller answer than "pane + /// not found" or "herdr did not answer in 15s". + why: String, + cpu: Option<f64>, + rss: Option<u64>, +} + +#[derive(Default)] +struct State { + agents: Vec<Agent>, + panels: Vec<Panel>, + labels: HashMap<String, String>, + err: String, +} + +/// What each pane's state was when we first saw it, so a duration can be +/// measured rather than guessed. +#[derive(Default)] +struct Seen { + since: HashMap<String, (String, f64, bool)>, + cpu: HashMap<i32, (u64, f64)>, + first_poll: bool, +} + +/// Fold a fresh /proc reading into the CPU history and return the percentage. +fn cpu_of(seen: &mut Seen, pid: i32, at: f64, hz: f64) -> (Option<f64>, Option<u64>) { + let Some((ticks, rss)) = proc_stats(pid) else { + return (None, None); + }; + // A percentage needs two readings; the first visit only records one. + let cpu = match seen.cpu.get(&pid) { + Some((was, when)) if at - when > 0.0 => { + Some((ticks.saturating_sub(*was)) as f64 / hz / (at - when) * 100.0) + } + _ => None, + }; + seen.cpu.insert(pid, (ticks, at)); + (cpu, Some(rss)) +} + +fn text_at(value: &serde_json::Value, key: &str) -> String { + value[key].as_str().unwrap_or("").to_string() +} + +/// What a pane's foreground process is, or why we could not tell. +/// +/// Three answers rather than two, and that is the whole point of the type. +/// A pane at its shell prompt and a probe that failed used to arrive here +/// as the same `None`, and the caller wrote that down as idle - so a herdr +/// that had stopped answering turned a board full of working panes into a +/// board full of resting ones, with nothing on screen saying otherwise. +enum Front { + /// Running something: its pid, argv, name and directory. + Running(i32, Vec<String>, String, String), + /// At its shell prompt - the foreground pid is the shell's own. + Prompt, + /// The probe did not answer, so which of the two this is nobody knows. + Unknown(String), +} + +/// What one `pane process-info` answer says the pane is doing. +/// +/// Split from the request so all three answers can be tested on a value +/// rather than on a live Herdr, the way `parse_proc_stat` is. +fn classify(info: &serde_json::Value) -> Front { + let process = &info["process_info"]; + let Some(front) = process["foreground_processes"] + .as_array() + .and_then(|a| a.first()) + else { + return Front::Unknown("no foreground process reported".into()); + }; + let Some(pid) = front["pid"].as_i64() else { + return Front::Unknown("foreground process has no pid".into()); + }; + // The shell's own pid in the foreground is the prompt being what is in + // the foreground. An absent `shell_pid` is not that - it says nothing - + // so it goes on as running rather than resting, which is the direction + // that cannot repeat the bug this type exists for. + if process["shell_pid"].as_i64() == Some(pid) { + return Front::Prompt; + } + let argv = front["argv"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + Front::Running( + pid as i32, + argv, + text_at(front, "name"), + text_at(front, "cwd"), + ) +} + +/// The foreground process of a pane, the prompt, or the failed probe. +fn foreground(pane_id: &str) -> Front { + match herdr_result(&["pane", "process-info", "--pane", pane_id]) { + Ok(info) => classify(&info), + Err(why) => Front::Unknown(why), + } +} + +fn poll(state: &Arc<Mutex<State>>, seen: &mut Seen, hz: f64) { + let mut labels = HashMap::new(); + if let Some(res) = herdr(&["workspace", "list"]) { + for w in res["workspaces"].as_array().into_iter().flatten() { + labels.insert(text_at(w, "workspace_id"), text_at(w, "label")); + } + } + let listed = match herdr(&["agent", "list"]) { + Some(res) => res, + None => { + if let Ok(mut guard) = state.lock() { + guard.err = "herdr CLI unavailable (is HERDR_ENV set?)".into(); + } + return; + } + }; + + let at = now(); + let mut agents = Vec::new(); + for entry in listed["agents"].as_array().into_iter().flatten() { + let pane_id = text_at(entry, "pane_id"); + let state_name = match entry["agent_status"].as_str() { + Some(s) if !s.is_empty() => s.to_string(), + _ => "unknown".to_string(), + }; + let was = seen.since.get(&pane_id); + if was.is_none_or(|(had, _, _)| *had != state_name) { + // A state already in place when we started is only a lower + // bound - we did not see it begin. + seen.since + .insert(pane_id.clone(), (state_name.clone(), at, !seen.first_poll)); + } + let (_, began, exact) = seen.since[&pane_id].clone(); + let (cpu, rss) = match foreground(&pane_id) { + Front::Running(pid, _, _, _) => cpu_of(seen, pid, at, hz), + // An agent's state comes from `agent list`, not from the probe, + // so a failed probe costs the row its CPU and memory and + // nothing else - and those already draw as `-` and `--` when + // there is no reading, which there is not. + Front::Prompt | Front::Unknown(_) => (None, None), + }; + agents.push(Agent { + name: text_at(entry, "agent"), + workspace_id: text_at(entry, "workspace_id"), + title: text_at(entry, "terminal_title_stripped"), + cwd: text_at(entry, "cwd"), + state: state_name, + since: at - began, + exact, + cpu, + rss, + pane_id, + }); + } + agents.sort_by(|a, b| { + rank_of(&a.state) + .cmp(&rank_of(&b.state)) + .then(b.since.total_cmp(&a.since)) + }); + + let mut panels = Vec::new(); + if let Some(listing) = herdr(&["pane", "list"]) { + for pane in listing["panes"].as_array().into_iter().flatten() { + if pane.get("agent").is_some_and(|a| !a.is_null()) { + continue; + } + let pane_id = text_at(pane, "pane_id"); + let front = foreground(&pane_id); + let (cpu, rss) = match &front { + Front::Running(pid, _, _, _) => cpu_of(seen, *pid, at, hz), + Front::Prompt | Front::Unknown(_) => (None, None), + }; + let (doing, command, cwd, why) = match front { + Front::Running(_, argv, name, cwd) => ( + Doing::Running, + command_label(&argv, &name), + if cwd.is_empty() { text_at(pane, "cwd") } else { cwd }, + String::new(), + ), + Front::Prompt => ( + Doing::Prompt, + String::new(), + text_at(pane, "cwd"), + String::new(), + ), + // The pane's own directory still comes from `pane list`, so + // an unread pane is not a blank row - it is a row that says + // where it is and that nobody could see into it. + Front::Unknown(why) => ( + Doing::Unknown, + String::new(), + text_at(pane, "cwd"), + why, + ), + }; + panels.push(Panel { + tab_id: text_at(pane, "tab_id"), + workspace_id: text_at(pane, "workspace_id"), + doing, + why, + pane_id, + command, + cwd, + cpu, + rss, + }); + } + } + // Unread first, then busy, and the busiest of those first: the point of + // the section is what is costing something, and above that, what the + // widget could not find out at all. + // Idle last, and that ordering is load-bearing twice over. The screen + // draws `busy` then `resting`, and both the cursor and the window are + // indices into `agents ++ panels` - so the two agree only because + // `panels` is already unread-then-running-then-resting. Reorder this and + // the cursor silently marks one pane while enter switches to another. + panels.sort_by(|a, b| { + doing_rank(a.doing) + .cmp(&doing_rank(b.doing)) + .then(b.cpu.unwrap_or(0.0).total_cmp(&a.cpu.unwrap_or(0.0))) + }); + + if let Ok(mut guard) = state.lock() { + guard.agents = agents; + guard.panels = panels; + guard.labels = labels; + guard.err.clear(); + } + seen.first_poll = false; +} + +/// A duration as this widget says it, which is not how the others do. +/// +/// Between an hour and a day it carries the minutes too: an agent blocked +/// for "3h" and one blocked for "3h58m" are the same number of hours and a +/// very different amount of ignoring. +fn ago(seconds: f64) -> String { + let s = seconds.max(0.0) as i64; + if s < 60 { + format!("{}s", s) + } else if s < 3600 { + format!("{}m", s / 60) + } else if s < 86400 { + format!("{}h{:02}m", s / 3600, s % 3600 / 60) + } else { + format!("{}d", s / 86400) + } +} + +/// Resident memory in five cells, whatever there is to say. +/// +/// herdr-panes.py returns five characters for a reading and six for an +/// absent one, so a process /proc would not name pushes the workspace +/// column one cell right of every other row. The header allots five. +fn mem(bytes: Option<u64>) -> String { + let Some(bytes) = bytes else { + return " --".to_string(); + }; + let mut value = bytes as f64; + for unit in ["B", "K", "M", "G"] { + if value < 1024.0 { + return format!("{:>4.0}{}", value, unit); + } + value /= 1024.0; + } + format!("{:>4.1}T", value) +} + +fn percent(cpu: Option<f64>) -> String { + match cpu { + Some(v) => format!("{:>4.0}%", v), + None => " -".to_string(), + } +} + +/// Where a row points, so Enter knows what to focus. +#[derive(Clone)] +enum Row { + Agent(Agent), + Process(Panel), +} + +struct Palette { + blocked: String, + blocked_lit: String, + done: String, + working: String, + idle: String, + idle_lit: String, + unknown: String, + unknown_lit: String, + dim: String, + /// A colour to draw over the selected-row tint. + /// + /// `dim` is 3.81 against `bg(38, 56, 76)`, under the 4.5 CLAUDE.md asks for + /// against the tint as well as the background. This is the same grey lifted + /// until it clears - 4.94 - and it is used *only* where a tint is on, so an + /// untinted row is exactly the colour it always was. Not quite the same as + /// "unselected": herdr-panes tints a blocked or done row whether or not it + /// is selected, and those get the lighter colours too. + /// + /// The substitution happens inside the closure that composes the tint, not + /// at each call site. Seventeen sites were counted when this was found and + /// there were twenty-three by the time it was fixed; more than half of them + /// reach `dim` through a condition that has nothing to do with selection - + /// `if count > 0 { loud } else { dim }` - and a zero count is the normal + /// state, so those are the common case rather than the rare one. Anyone + /// fixing this a call site at a time would fix the obvious half. + dim_lit: String, + txt: String, + lbl: String, + accent: String, + proc: String, + idle_c: String, + idle_c_lit: String, +} + +fn palette() -> Palette { + Palette { + blocked: tc::rgb(255, 105, 115), + blocked_lit: tc::rgb(255, 128, 136), + done: tc::rgb(90, 240, 160), + working: tc::rgb(255, 200, 90), + idle: tc::rgb(128, 148, 172), + idle_lit: tc::rgb(152, 168, 188), + unknown: tc::rgb(150, 150, 165), + unknown_lit: tc::rgb(165, 165, 178), + dim: tc::rgb(127, 147, 172), + dim_lit: tc::rgb(140, 170, 195), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + accent: tc::rgb(150, 210, 255), + proc: tc::rgb(170, 190, 215), + idle_c: tc::rgb(122, 138, 160), + idle_c_lit: tc::rgb(155, 167, 184), + } +} + +fn colour_of<'a>(state: &str, p: &'a Palette) -> &'a str { + match state { + "blocked" => &p.blocked, + "done" => &p.done, + "working" => &p.working, + "idle" => &p.idle, + _ => &p.unknown, + } +} + +fn mark_of(state: &str, tick: usize) -> char { + match state { + "blocked" => '⚠', + "done" => '✓', + "working" => SPINNER[tick % SPINNER.len()], + "idle" => '·', + _ => '?', + } +} + +/// The home-relative form of a directory, which is how a person names it. +fn homely(path: &str) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + if home.is_empty() { + return path.to_string(); + } + let projects = format!("{}/projects/", home); + if let Some(rest) = path.strip_prefix(&projects) { + return rest.to_string(); + } + match path.strip_prefix(&home) { + Some(rest) => format!("~{}", rest), + None => path.to_string(), + } +} + +fn main() { + tc::maybe_help(include_str!("herdr-panes_help.txt")); + // The section is spelled with an underscore while everything else about + // this widget is hyphenated. A mismatched key is read as absent rather + // than as an error, so it is worth saying out loud. + let cfg = tc::load_config("herdr_panes"); + let mut refresh = tc::poll_secs(tc::cfg_f64(&cfg, "refresh", 4.0), 4.0); + let args: Vec<String> = std::env::args().skip(1).collect(); + if args.len() >= 2 && (args[0] == "-n" || args[0] == "--refresh") { + refresh = tc::poll_secs(args[1].parse().unwrap_or(4.0), 4.0).max(1.0); + } + + let absent = tc::missing(&["herdr"]); + if !absent.is_empty() { + tc::cannot_start( + "herdr panes", + &absent, + &[ + "This reads a running Herdr session through its own CLI: the", + "workspaces, the panes in them, and which agent is in which.", + "There is no other source for any of it.", + "", + "If Herdr is installed but not on PATH, this widget will find", + "it as soon as the shell can.", + ], + "see https://herdr.dev", + ); + return; + } + + let p = palette(); + let state = Arc::new(Mutex::new(State::default())); + let wake = Arc::new((Mutex::new(false), Condvar::new())); + let poller = Arc::clone(&state); + let poller_wake = Arc::clone(&wake); + std::thread::spawn(move || { + let hz = clock_ticks(); + let mut seen = Seen { + first_poll: true, + ..Default::default() + }; + loop { + // A poller that dies takes its explanation with it, and an empty + // board looks exactly like a Herdr with nothing running in it. + let step = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + poll(&poller, &mut seen, hz) + })); + if step.is_err() { + if let Ok(mut guard) = poller.lock() { + guard.err = "poller stopped - see the pane it was started from".into(); + } + return; + } + let (lock, cond) = &*poller_wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(refresh)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + *asked = false; + } + }); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let (mut show_labels, mut show_idle) = (true, true); + let (mut selected, mut tick) = (0usize, 0usize); + // How far down the three lists, read as one, the window has scrolled. + // Kept across frames so the view holds still while the cursor moves + // inside it, and only moves when the cursor would leave it. + let mut scroll = 0usize; + let mut note: Option<(String, bool, f64)> = None; + let mut rows_now: Vec<Row> = Vec::new(); + + loop { + tick += 1; + for key in keyboard.poll() { + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "r" | "R" => { + let (lock, cond) = &*wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + } + "l" | "L" => show_labels = !show_labels, + "i" | "I" => { + show_idle = !show_idle; + selected = 0; + } + "up" => selected = selected.saturating_sub(1), + "down" => selected += 1, + "home" => selected = 0, + "end" => selected = rows_now.len().saturating_sub(1), + "enter" | "f" | "F" => { + if let Some(row) = rows_now.get(selected.min(rows_now.len().saturating_sub(1))) + { + let (ok, what, pane) = match row { + Row::Agent(a) => ( + herdr_action(&["agent", "focus", &a.pane_id]), + a.name.clone(), + a.pane_id.clone(), + ), + // A pane has no focus-by-id, but a tab tiles its + // panes, so focusing the tab brings it into view. + Row::Process(n) => ( + herdr_action(&["tab", "focus", &n.tab_id]), + n.command.clone(), + n.pane_id.clone(), + ), + }; + note = Some(( + if ok { + format!("→ focused {} in {}", what, pane) + } else { + format!("! could not focus {}", pane) + }, + ok, + now() + 3.0, + )); + } + } + _ => {} + } + } + + let (w, h) = tc::size(); + let (agents, panels, labels, err) = match state.lock() { + Ok(g) => ( + g.agents.clone(), + g.panels.clone(), + g.labels.clone(), + g.err.clone(), + ), + Err(_) => return, + }; + // Everything that is not known to be at a prompt shares the + // PROCESSES section, unread panes at the top of it. They are counted + // apart in the heading, because "running something" is a claim and + // the whole point of the unread ones is that the claim cannot be + // made. [i] hides only the panes we know are resting: hiding one we + // could not read would be the old bug wearing the new type. + let busy: Vec<&Panel> = panels.iter().filter(|n| n.doing != Doing::Prompt).collect(); + let resting: Vec<&Panel> = panels.iter().filter(|n| n.doing == Doing::Prompt).collect(); + let unread: Vec<&&Panel> = busy.iter().filter(|n| n.doing == Doing::Unknown).collect(); + let running = busy.len() - unread.len(); + rows_now = agents + .iter() + .cloned() + .map(Row::Agent) + .chain( + panels + .iter() + .filter(|n| show_idle || n.doing != Doing::Prompt) + .cloned() + .map(Row::Process), + ) + .collect(); + if !rows_now.is_empty() && selected >= rows_now.len() { + selected = rows_now.len() - 1; + } + if note.as_ref().is_some_and(|(_, _, until)| now() >= *until) { + note = None; + } + + let mut counts: HashMap<&str, usize> = HashMap::new(); + for a in &agents { + *counts.entry(a.state.as_str()).or_insert(0) += 1; + } + let places: std::collections::HashSet<&str> = + agents.iter().map(|a| a.workspace_id.as_str()).collect(); + + let mut rows = vec![tc::title("herdr panes", w, &p.accent)]; + let mut summary = vec![ + ( + p.dim.as_str(), + format!(" {} agent{}", agents.len(), plural(agents.len())), + ), + ( + p.dim.as_str(), + format!(" · {} workspace{}", places.len(), plural(places.len())), + ), + ]; + for state_name in ["blocked", "done", "working", "idle"] { + if let Some(n) = counts.get(state_name) { + summary.push((colour_of(state_name, &p), format!(" {} {}", n, state_name))); + } + } + rows.push(tc::seg(&summary, w - 1)); + if !err.is_empty() { + rows.push(tc::seg(&[(p.blocked.as_str(), format!(" ! {}", err))], w - 1)); + } + + let wants = counts.get("blocked").copied().unwrap_or(0) + + counts.get("done").copied().unwrap_or(0); + rows.push(if wants > 0 { + tc::seg( + &[( + if counts.contains_key("blocked") { + p.blocked.as_str() + } else { + p.done.as_str() + }, + format!(" ▸ {} agent{} waiting for you", wants, plural(wants)), + )], + w - 1, + ) + } else { + tc::seg(&[(p.dim.as_str(), " nothing waiting on you".into())], w - 1) + }); + rows.push(String::new()); + + let wide = w >= 66; + + // The footer must always be the last visible line, so it is built + // before the body rather than after it: it wraps, so how many rows + // it takes depends on the width, and the body cannot know its own + // budget until that is settled. Each section budgeting for itself is + // what drifted before, and left the footer written past the bottom. + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " select".into())], + vec![ + (p.accent.as_str(), "↵".into()), + (p.dim.as_str(), " switch to this pane".into()), + ], + vec![(p.dim.as_str(), "[i]dle".into())], + vec![(p.dim.as_str(), "[l]abels".into())], + vec![(p.dim.as_str(), "[r]efresh".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + let footer: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + + // One window over the three lists read as one, which is the order + // `rows_now` is in and the order the keys walk. The cursor used to + // be clamped against the whole list while each section stopped at a + // row budget of its own, so on any pane too short for everything the + // cursor walked past the last drawn row and vanished - and enter + // still acted on whatever it was invisibly sitting on. + let idle_listed = show_idle && !resting.is_empty(); + // Every heading is drawn, always: AGENTS and its column head, a + // blank and PROCESSES, and the same again for IDLE when there is an + // idle section at all. TOY-34's rule survives that way rather than + // by rationing - the heading and its count are never what gets cut. + // Everything that is not an entry row: the pinned header already + // pushed, each section's heading and column head, the note line and + // the footer. Counted rather than estimated, because one row short + // is one entry admitted that the truncate below then cuts - and the + // row it cuts is the last one, which is where the cursor is when you + // have just pressed end. + let chrome = rows.len() + + 2 // AGENTS, and its column head + + 2 + usize::from(wide) // blank, PROCESSES, its column head + + usize::from(!unread.is_empty()) // why they could not be read + + usize::from(agents.is_empty()) // the line that stands in for a list + + usize::from(busy.is_empty()) + + if idle_listed { 2 } else { 0 } // blank, IDLE + + footer.len() + + 1; // the note line + let room = h.saturating_sub(chrome).max(1); + // An agent takes two rows and a pane takes one, in the order the + // keys walk them. + let heights: Vec<usize> = std::iter::repeat(2) + .take(agents.len()) + .chain(std::iter::repeat(1).take(rows_now.len() - agents.len())) + .collect(); + let window = window_over(&heights, selected, room, scroll); + scroll = window.start; + + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── AGENTS ── ".into()), + (p.dim.as_str(), format!("{}", agents.len())), + (p.dim.as_str(), showing(&window, 0, agents.len())), + ], + w - 1, + )); + let mut head = format!(" {:<8} {:<8} {:<6} {:<5}", "AGENT", "STATE", "FOR", "CPU"); + if wide { + head += &format!(" {:<5} {:<18}", "MEM", "WORKSPACE"); + } + rows.push(tc::seg(&[(p.dim.as_str(), tc::pad(&head, w - 1))], w - 1)); + + for (i, a) in agents.iter().enumerate() { + if !window.contains(&i) { + continue; + } + let here = i == selected; + let colour = colour_of(&a.state, &p); + // Blocked and done keep a tint of their own even unselected: the + // whole point is that they are visible without being looked for. + let loud = a.state == "blocked" || a.state == "done"; + let tint = if here { + tc::bg(38, 56, 76) + } else if a.state == "blocked" { + tc::bg(46, 26, 30) + } else if a.state == "done" { + tc::bg(22, 46, 34) + } else { + String::new() + }; + let c = |colour: &str| { + // Any colour that would not clear AA on this tint is swapped + // for its lighter twin. `dim` was measured first; a review + // found the others after the first fix shipped saying it was + // done, so they are here by measurement rather than by guess. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else if colour == p.idle { + p.idle_lit.as_str() + } else if colour == p.unknown { + p.unknown_lit.as_str() + } else if colour == p.blocked { + p.blocked_lit.as_str() + } else if colour == p.idle_c { + p.idle_c_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let name: String = a.name.chars().take(6).collect(); + let state_cell = if loud { + a.state.to_uppercase() + } else { + a.state.clone() + }; + let heat = match a.cpu { + Some(v) if v > 0.0 => tc::heat((v / 100.0).min(1.0)), + _ => p.dim.clone(), + }; + let mut line = vec![ + ( + c(colour), + format!( + "{}{} {:<6}", + if here { "▸" } else { " " }, + mark_of(&a.state, tick), + name + ), + ), + (c(colour), format!(" {:<8}", state_cell)), + ( + c(&p.dim), + format!(" {:<6}", format!("{}{}", if a.exact { "" } else { "≥" }, ago(a.since))), + ), + (c(&heat), percent(a.cpu)), + ]; + if wide { + let place = if show_labels { + let label = labels.get(&a.workspace_id).cloned().unwrap_or_default(); + if label.is_empty() { a.workspace_id.clone() } else { label } + } else { + a.pane_id.clone() + }; + line.push((c(&p.dim), format!(" {}", mem(a.rss)))); + line.push((c(&p.accent), format!(" {}", tc::pad(&place, 18)))); + } + if loud || here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + if rows.len() < h.saturating_sub(1) { + let body = if loud || here { &p.txt } else { &p.dim }; + rows.push(tc::seg( + &[ + (&c(&p.dim), format!(" {} ", homely(&a.cwd))), + (&c(body), a.title.trim().to_string()), + ( + &tint, + if loud || here { " ".repeat(w) } else { String::new() }, + ), + ], + w - 1, + )); + } + } + if agents.is_empty() && err.is_empty() { + rows.push(tc::seg(&[(p.dim.as_str(), " no agents running".into())], w - 1)); + } + + rows.push(String::new()); + let mut heading = vec![ + (p.lbl.as_str(), " ── PROCESSES ── ".into()), + ( + p.dim.as_str(), + format!("{} pane{} running something", running, plural(running)), + ), + ]; + if !unread.is_empty() { + heading.push(( + p.unknown.as_str(), + format!(" · {} could not be read", unread.len()), + )); + } + heading.push((p.dim.as_str(), showing(&window, agents.len(), busy.len()))); + rows.push(tc::seg(&heading, w - 1)); + if wide { + rows.push(tc::seg( + &[( + p.dim.as_str(), + tc::pad( + &format!(" {:<20} {:<5} {:<5} {:<18}", "COMMAND", "CPU", "MEM", "WORKSPACE"), + w - 1, + ), + )], + w - 1, + )); + } + // The reason, on a line of its own rather than after the count in + // the heading: on an eighty-column pane the heading runs out of room + // exactly where the reason starts, and the reason is the half worth + // keeping. They fail one reason at a time - the socket, or a pane + // going away between the listing and the probe - so the first one + // speaks for all of them. + if let Some(n) = unread.first() { + let why = if n.why.is_empty() { "no reason given" } else { &n.why }; + rows.push(tc::seg( + &[(p.unknown.as_str(), format!(" ⚠ {}", why))], + w - 1, + )); + } + for (j, n) in busy.iter().enumerate() { + if !window.contains(&(agents.len() + j)) { + continue; + } + let here = agents.len() + j == selected; + let tint = if here { tc::bg(38, 56, 76) } else { String::new() }; + let c = |colour: &str| { + // Any colour that would not clear AA on this tint is swapped + // for its lighter twin. `dim` was measured first; a review + // found the others after the first fix shipped saying it was + // done, so they are here by measurement rather than by guess. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else if colour == p.idle { + p.idle_lit.as_str() + } else if colour == p.unknown { + p.unknown_lit.as_str() + } else if colour == p.blocked { + p.blocked_lit.as_str() + } else if colour == p.idle_c { + p.idle_c_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let heat = match n.cpu { + Some(v) if v > 0.0 => tc::heat((v / 100.0).min(1.0)), + _ => p.dim.clone(), + }; + // An unread pane says so in words where a command would go. A + // bare "?" is what a running pane with unparseable argv shows, + // and the two are not the same thing at all. + let unreadable = n.doing == Doing::Unknown; + let mut line = vec![ + ( + c(if unreadable { &p.unknown } else { &p.proc }), + format!( + "{}{} ", + if here { "▸" } else { " " }, + if unreadable { '⚠' } else { '▪' } + ), + ), + ( + c(if unreadable { &p.unknown } else { &p.txt }), + tc::pad( + if unreadable { + "could not be read" + } else if n.command.is_empty() { + "?" + } else { + &n.command + }, + 20, + ), + ), + (c(&heat), percent(n.cpu)), + ]; + if wide { + let place = if show_labels { + let label = labels.get(&n.workspace_id).cloned().unwrap_or_default(); + if label.is_empty() { n.workspace_id.clone() } else { label } + } else { + n.pane_id.clone() + }; + line.push((c(&p.dim), format!(" {}", mem(n.rss)))); + line.push((c(&p.accent), format!(" {}", tc::pad(&place, 18)))); + } + if here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + // True only when nothing was unread either: an empty section with a + // pane the probe failed on is not a Herdr where everything rests. + if busy.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " every other pane is idle at a prompt".into())], + w - 1, + )); + } + + // The idle section's heading is drawn whenever there is an idle + // section at all, and it is never what gets cut - that was TOY-34: + // dropping it silently left the footer offering [i]dle with nothing + // behind it. Rationing rows for it is no longer how that is kept. + // The window bounds the entries above, so the heading always fits, + // and it says how many are on screen when not all of them are. + if idle_listed { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── IDLE ── ".into()), + ( + p.dim.as_str(), + format!("{} pane{} at a prompt", resting.len(), plural(resting.len())), + ), + ( + p.dim.as_str(), + showing(&window, agents.len() + busy.len(), resting.len()), + ), + ], + w - 1, + )); + for (j, n) in resting.iter().enumerate() { + if !window.contains(&(agents.len() + busy.len() + j)) { + continue; + } + let here = agents.len() + busy.len() + j == selected; + let tint = if here { tc::bg(38, 56, 76) } else { String::new() }; + let c = |colour: &str| { + // Any colour that would not clear AA on this tint is swapped + // for its lighter twin. `dim` was measured first; a review + // found the others after the first fix shipped saying it was + // done, so they are here by measurement rather than by guess. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else if colour == p.idle { + p.idle_lit.as_str() + } else if colour == p.unknown { + p.unknown_lit.as_str() + } else if colour == p.blocked { + p.blocked_lit.as_str() + } else if colour == p.idle_c { + p.idle_c_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let place = if show_labels { + let label = labels.get(&n.workspace_id).cloned().unwrap_or_default(); + if label.is_empty() { n.workspace_id.clone() } else { label } + } else { + n.pane_id.clone() + }; + let mut line = vec![ + (c(&p.idle_c), format!("{}▫ ", if here { "▸" } else { " " })), + (c(&p.idle_c), tc::pad(&tail_path(&homely(&n.cwd), 26), 27)), + (c(&p.accent), tc::pad(&place, 18)), + ]; + if here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + } + + let reserve = footer.len() + 1; // +1 for the note line + rows.truncate(h.saturating_sub(reserve)); + while rows.len() < h.saturating_sub(reserve) { + rows.push(String::new()); + } + rows.push(match note.as_ref() { + Some((text, ok, _)) => tc::seg( + &[( + if *ok { p.done.as_str() } else { p.blocked.as_str() }, + format!(" {}", text), + )], + w - 1, + ), + None => String::new(), + }); + rows.extend(footer); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(250)); + } +} + +fn plural(n: usize) -> &'static str { + if n == 1 { + "" + } else { + "s" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_window_holds_the_cursor_whatever_the_rows_cost() { + // Fifteen agents at two rows each, then twenty-eight panes at one: + // the shape that broke it. A window counted in entries admits more + // rows than the pane has, they are cut off the bottom, and the + // cursor goes with them. + let heights: Vec<usize> = std::iter::repeat(2) + .take(15) + .chain(std::iter::repeat(1).take(28)) + .collect(); + for room in [1usize, 4, 12, 30, 200] { + for at in 0..heights.len() { + for from in [0usize, 5, 14, 30, 42] { + let w = window_over(&heights, at, room, from); + assert!(w.contains(&at), "room={} at={} from={} gave {:?}", room, at, from, w); + // And it fits, unless one entry alone is taller than the + // pane - in which case it is drawn anyway, because the + // alternative is drawing nothing. + let used: usize = heights[w.clone()].iter().sum(); + assert!( + used <= room || w.len() == 1, + "room={} at={} from={} drew {} rows in {:?}", + room, at, from, used, w + ); + } + } + } + } + + #[test] + fn the_window_holds_still_while_the_cursor_moves_inside_it() { + let heights = vec![1usize; 40]; + // Already on screen: the view does not jump under the reader. + assert_eq!(window_over(&heights, 12, 10, 8), 8..18); + assert_eq!(window_over(&heights, 8, 10, 8), 8..18); + assert_eq!(window_over(&heights, 17, 10, 8), 8..18); + // Off the bottom: it moves by exactly enough. + assert_eq!(window_over(&heights, 18, 10, 8), 9..19); + // Off the top: it moves to the cursor rather than past it. + assert_eq!(window_over(&heights, 3, 10, 8), 3..13); + // An empty list has no window at all. + assert_eq!(window_over(&[], 0, 10, 0), 0..0); + } + + #[test] + fn a_heading_says_its_range_only_when_the_section_is_cut() { + // Thirteen agents, then fifteen running panes, then fifteen idle. + let (agents, running, resting) = (13usize, 15usize, 15usize); + let (a, r, i) = (0, agents, agents + running); + + // A window holding everything says nothing about ranges. + let all = 0..43; + assert_eq!(showing(&all, a, agents), ""); + assert_eq!(showing(&all, r, running), ""); + assert_eq!(showing(&all, i, resting), ""); + + // A window over the middle: agents cut short, running cut at both + // ends, idle not reached. + let mid = 6..20; + assert_eq!(showing(&mid, a, agents), " · showing 7-13"); + assert_eq!(showing(&mid, r, running), " · showing 1-7"); + assert_eq!(showing(&mid, i, resting), " · none on screen"); + + // And past the end: the sections above say so rather than showing a + // count with no rows under it. + let low = 30..43; + assert_eq!(showing(&low, a, agents), " · none on screen"); + assert_eq!(showing(&low, i, resting), " · showing 3-15"); + + // An empty section has no range to give. + assert_eq!(showing(&mid, r, 0), ""); + } + + #[test] + fn the_idle_section_is_never_silently_absent() { + // TOY-34's rule, which this rewrite had to keep: dropping the idle + // section silently left the footer offering [i]dle with nothing + // behind it. + // + // It used to be kept by rationing rows - the section was granted a + // heading only if the lists above had left room. It is now kept by + // construction: the window bounds how many entry rows the lists + // above can take, so the heading always fits, and when the window + // has scrolled past the idle panes the heading says that rather + // than nothing. + for start in 0..40usize { + for room in 1..12usize { + let window = start..start + room; + let said = showing(&window, 28, 15); + assert!( + !said.is_empty() || (window.start <= 28 && window.end >= 43), + "window {:?} says nothing about a section it does not hold", + window + ); + } + } + } + + + #[test] + fn a_runner_gives_way_to_the_script_it_was_handed() { + // "python3" and "node" say nothing about what a pane is doing. + let argv = |s: &str| -> Vec<String> { + s.split_whitespace().map(String::from).collect() + }; + assert_eq!( + command_label(&argv("/usr/bin/python3 /home/w/toys/netwatch.py"), ""), + "netwatch.py" + ); + // Flags are skipped to reach the script behind them. + assert_eq!( + command_label(&argv("node --inspect /srv/app/server.js"), ""), + "server.js" + ); + // Anything that is not a runner is its own answer. + assert_eq!(command_label(&argv("/usr/bin/htop"), ""), "htop"); + // A runner with nothing after it is still the runner. + assert_eq!(command_label(&argv("bash"), ""), "bash"); + // No argv at all falls back to the name the kernel reports. + assert_eq!(command_label(&[], "cargo"), "cargo"); + assert_eq!(command_label(&[], ""), "?"); + } + + #[test] + fn a_cut_path_says_that_it_was_cut() { + assert_eq!(tail_path("/home/w/projects/toys", 40), "/home/w/projects/toys"); + // The end is kept, because that is the part that names the thing. + assert_eq!(tail_path("/home/w/projects/toys", 10), "…ects/toys"); + assert_eq!(tail_path("/home/w/projects/toys", 10).chars().count(), 10); + } + + #[test] + fn a_duration_carries_minutes_between_an_hour_and_a_day() { + assert_eq!(ago(45.0), "45s"); + assert_eq!(ago(600.0), "10m"); + // Three hours and one minute is not the same as three hours, and an + // agent blocked that long is the reason this widget exists. + assert_eq!(ago(3660.0), "1h01m"); + assert_eq!(ago(14_280.0), "3h58m"); + assert_eq!(ago(90_000.0), "1d"); + assert_eq!(ago(-5.0), "0s"); + } + + #[test] + fn memory_keeps_its_column_width() { + // Five, matching the header - the Python gives six to the absent + // case, which is what this caught. + for value in [Some(0), Some(4096), Some(1_600_000_000), None] { + assert_eq!(mem(value).chars().count(), 5, "{:?}", value); + } + assert_eq!(mem(Some(512)), " 512B"); + assert_eq!(mem(Some(1024 * 1024 * 3)), " 3M"); + assert_eq!(mem(None), " --"); + } + + #[test] + fn the_states_that_want_a_human_sort_first() { + assert!(rank_of("blocked") < rank_of("done")); + assert!(rank_of("done") < rank_of("working")); + assert!(rank_of("working") < rank_of("idle")); + assert!(rank_of("idle") < rank_of("unknown")); + // A state Herdr grows later sorts last rather than crashing. + assert!(rank_of("reticulating") > rank_of("unknown")); + } + + #[test] + fn a_proc_stat_line_gives_up_its_cpu_and_memory() { + // The command sits in brackets and can contain spaces and brackets + // of its own, which is why the fields are counted from the last one. + let line = format!( + "42 (my (odd) proc) S 1 42 42 0 -1 4194304 100 0 0 0 {} {} 0 0 20 0 8 0 900 0 {} 0", + 310, 90, 4096 + ); + let (ticks, rss) = parse_proc_stat(&line).expect("a well-formed line should parse"); + assert_eq!(ticks, 400, "utime and stime are summed"); + assert_eq!(rss, 4096 * 4096, "rss is in pages, reported in bytes"); + // A truncated line has no fields to find and must not be guessed at. + assert_eq!(parse_proc_stat("42 (short) S 1 2 3"), None); + } + + #[test] + fn a_failed_command_is_told_from_a_quiet_one() { + // What herdr answers a request it cannot serve, verbatim in shape: + // an `error` object, no `result`, and exit status 0. Nothing about + // having run the command says it did not work, so a reader looking + // only for `result` cannot tell this from a pane with nothing to + // report - which is how a failed probe used to become "idle". + let failed = r#"{"error":{"code":"pane_not_found","message":"pane not found"},"id":"p"}"#; + assert_eq!(result_of(failed).unwrap_err(), "pane not found"); + // An error with no message still has to say something. + assert!(!result_of(r#"{"error":{"code":"busy"}}"#) + .unwrap_err() + .is_empty()); + // Output that is not JSON at all is a failure, not a silence. + assert!(result_of("herdr: no server on this socket").is_err()); + // A null result is nothing, and says so rather than handing it on. + assert!(result_of(r#"{"id":"p","result":null}"#).is_err()); + // And a result that is there arrives whole. + let answered = r#"{"id":"p","result":{"process_info":{"pane_id":"w1:p1"}}}"#; + assert_eq!( + result_of(answered).unwrap()["process_info"]["pane_id"], + "w1:p1" + ); + } + + #[test] + fn a_pane_at_its_prompt_and_a_pane_nobody_could_read_are_not_one_answer() { + let info = |front: serde_json::Value, shell: i64| { + serde_json::json!({ + "process_info": {"foreground_processes": [front], "shell_pid": shell} + }) + }; + let shell = serde_json::json!({ + "pid": 200, "argv": ["/bin/bash"], "name": "bash", "cwd": "/w" + }); + // The foreground pid is the shell's own: the prompt is what is in + // front. This is the only thing that means idle. + assert!(matches!(classify(&info(shell, 200)), Front::Prompt)); + + let build = serde_json::json!({ + "pid": 311, "argv": ["/usr/bin/python3", "/w/build.py"], + "name": "python3", "cwd": "/w" + }); + match classify(&info(build, 200)) { + Front::Running(pid, argv, name, cwd) => { + assert_eq!(pid, 311); + assert_eq!(command_label(&argv, &name), "build.py"); + assert_eq!(cwd, "/w"); + } + _ => panic!("a pane running something is running something"), + } + + // An answer with nothing readable in it is neither of those. It used + // to fall through to the same `None` as the prompt, and the pane + // joined IDLE with no sign that anything had gone wrong. + assert!(matches!(classify(&serde_json::json!({})), Front::Unknown(_))); + assert!(matches!( + classify(&info(serde_json::json!({"argv": ["sh"]}), 200)), + Front::Unknown(_) + )); + + // An absent shell_pid says nothing, so it cannot say "prompt". + let alone = serde_json::json!({ + "process_info": {"foreground_processes": [{"pid": 7, "name": "vi"}]} + }); + assert!(matches!(classify(&alone), Front::Running(7, _, _, _))); + } + + #[test] + fn the_unread_panes_sort_above_the_busy_ones() { + // The section draws in this order and the cursor indexes it, so the + // rank is what keeps the two agreeing - and it puts the row the + // widget could not answer for at the top, where a failure belongs. + assert!(doing_rank(Doing::Unknown) < doing_rank(Doing::Running)); + assert!(doing_rank(Doing::Running) < doing_rank(Doing::Prompt)); + // A pane nobody has looked at yet is unread, not resting. + assert_eq!(Doing::default(), Doing::Unknown); + } +} diff --git a/widgets/src/bin/herdr-panes_help.txt b/widgets/src/bin/herdr-panes_help.txt new file mode 100644 index 0000000..d3ab4a0 --- /dev/null +++ b/widgets/src/bin/herdr-panes_help.txt @@ -0,0 +1,40 @@ +Everything running in Herdr, across every workspace. + +Two sections. AGENTS lists recognised coding agents with the lifecycle state +Herdr reports, ordered so the ones wanting a human come first. PROCESSES lists +every other pane that is actually running something — dev servers, monitors, +builds — with what it is running and what it costs. IDLE lists the panes +sitting at a shell prompt, by directory, so they can still be jumped to; +toggle that section with i. + +Enter jumps to whatever is selected: the agent's pane, or the tab holding that +process. + +A Herdr client, not a general agent monitor: the inventory and the lifecycle +states come from `herdr agent list`, the workspace labels from +`herdr workspace list`, and the pid behind each pane from +`herdr pane process-info`. Any agent kind Herdr recognises appears here with +no change to this file. + +On a terminal server hosting many workspaces, agents finish or get stuck in +places you are not currently looking. This lists every agent with the state +Herdr reports, ordered so the ones wanting your attention are at the top: + + blocked waiting on an approval or a question, right now + done finished background work you have not looked at yet + working busy + idle ready for input + unknown an agent is present but Herdr cannot classify it + +Each row also carries the workspace, how long the agent has held its current +state, and the real CPU and memory of its process. A duration is prefixed with +≥ when the state was already in place before this tool started, since then it +is only a lower bound. + + herdr-panes [-n SECONDS] + +Keys: up/down select, Enter (or f) focuses that agent's pane so you jump +straight to whatever needs you, l toggles workspace labels vs pane ids, +i shows or hides the idle section, +r refreshes now, q quits. +Requires HERDR_ENV; it shells out to the `herdr` CLI. diff --git a/widgets/src/bin/latency.rs b/widgets/src/bin/latency.rs new file mode 100644 index 0000000..86047d1 --- /dev/null +++ b/widgets/src/bin/latency.rs @@ -0,0 +1,1525 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Multi-target latency monitor. +//! +//! A port of latency.py. One ping per target, read line by line as it +//! arrives, so the numbers are what ping measured rather than anything this +//! timed itself. + +/// The hues targets are drawn in, kept as numbers rather than escapes so +/// that a faded set can be mixed from the same nine. +const HUES: &[(u8, u8, u8)] = &[ + (90, 220, 255), + (255, 170, 80), + (140, 255, 160), + (230, 140, 255), + (255, 110, 130), + (255, 230, 110), + (120, 160, 255), + (255, 140, 200), + (150, 255, 240), +]; + +/// The dark these widgets are drawn against. +/// +/// Not the terminal's real background - that cannot be asked for - but the +/// one the palette was chosen for, and what a trace is mixed toward when it +/// is not the one being looked at. +const BACKDROP: (u8, u8, u8) = (16, 22, 30); + +/// How far an unlooked-at trace is mixed toward the backdrop. +/// +/// Measured rather than chosen by eye, against the two things it sits +/// between. At 0.60 a faded trace reads 2.04 against the backdrop where the +/// axis furniture reads 1.55, so it stays visibly ink rather than sinking +/// into the chrome; and it is 3.29 from its own full-strength hue, clearing +/// the 3.0 that separates two graphical tones. Fading further wins +/// separation and loses the trace into the grid - by 0.65 it is dimmer than +/// the axis it is drawn over. +const FADE: f64 = 0.60; + +use std::io::{BufRead, BufReader}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use toys_core as tc; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +#[derive(Clone, Default)] +struct Target { + label: String, + ip: String, + samples: Vec<(f64, Option<f64>)>, // (when, rtt or a loss) + down_since: Option<f64>, + /// Whether the last reading was an answer, for the dot beside the name. + alive: bool, + /// The live ping, so a new interval can be applied without waiting for + /// the old one to notice. + pid: Option<i32>, + /// Set while we are killing our own ping on purpose, so its exit is not + /// logged as an outage. + restarting: bool, + /// Why this target has no reading, when the reason is this widget's own + /// rather than the network's. A host that does not answer is a result; + /// a `ping` that will not start is a failure, and the two used to look + /// identical - an empty row either way, retried every two seconds for + /// as long as the widget was up, saying nothing. + err: String, +} + +/// Everything the table says about one target over the retained window. +#[derive(Default)] +struct Stats { + now: Option<f64>, + avg: Option<f64>, + med: Option<f64>, + min: Option<f64>, + max: Option<f64>, + jit: Option<f64>, + loss: f64, + n: usize, +} + +impl Target { + /// Record one reading and keep only the last `window` of them. + /// + /// Retention lives here rather than beside a push because it used to + /// live beside one push and not the other: the trim sat inside the loop + /// that reads `ping`'s output, so a target whose `ping` exits at once - + /// an unresolvable name, a host with no route to it - appended a loss + /// from the retry path every two seconds and never trimmed. The vector + /// grew for as long as the pane was up and `stats()` reread the whole + /// of it every frame. One door in, one rule. + fn record(&mut self, at: f64, rtt: Option<f64>, window: usize) { + self.samples.push((at, rtt)); + if self.samples.len() > window { + let drop = self.samples.len() - window; + self.samples.drain(..drop); + } + } + + /// Round trips that arrived, newest last. + fn rtts(&self) -> Vec<f64> { + self.samples.iter().filter_map(|(_, r)| *r).collect() + } + + fn stats(&self) -> Stats { + let got = self.rtts(); + let total = self.samples.len(); + let lost = total - got.len(); + let loss = if total > 0 { + 100.0 * lost as f64 / total as f64 + } else { + 0.0 + }; + if got.is_empty() { + return Stats { + loss: if total > 0 { 100.0 } else { 0.0 }, + n: total, + ..Default::default() + }; + } + let mut ordered = got.clone(); + ordered.sort_by(f64::total_cmp); + // The mean gap between one reply and the next, which is what jitter + // means on a link: how much the round trip moves from ping to ping, + // not how far it sits from its own average. + let jit = if got.len() > 1 { + Some( + got.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>() + / (got.len() - 1) as f64, + ) + } else { + Some(0.0) + }; + Stats { + now: self.samples.last().and_then(|(_, r)| *r), + avg: Some(got.iter().sum::<f64>() / got.len() as f64), + med: Some(ordered[ordered.len() / 2]), + min: Some(ordered[0]), + max: Some(ordered[ordered.len() - 1]), + jit, + loss, + n: total, + } + } +} + +/// A round trip in a fixed seven cells, so the columns cannot shift. +/// +/// Below a millisecond it changes unit rather than losing the value: a +/// loopback reply reads 0.21ms as 210µs, and two decimal places of a +/// millisecond hides what that means. +fn fmt_ms(value: Option<f64>) -> String { + match value { + None => " -- ".to_string(), + Some(v) if v < 1.0 => format!("{:>5.0}µs", v * 1000.0), + Some(v) if v < 100.0 => format!("{:>5.2}ms", v), + Some(v) => format!("{:>5.1}ms", v), + } +} + +const SPARK: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +/// One target's recent history at one character per ping. +/// +/// Scaled to its own range rather than the chart's, so a target that never +/// leaves a two-millisecond band still shows the shape of its variation - +/// which is the question this line answers and the shared chart does not. +fn sparkline(samples: &[(f64, Option<f64>)], n: usize, p: &Palette) -> Vec<(String, String)> { + let window = &samples[samples.len().saturating_sub(n)..]; + let got: Vec<f64> = window.iter().filter_map(|(_, r)| *r).collect(); + if got.is_empty() { + return vec![(p.bad.clone(), "×".repeat(window.len().min(n)))]; + } + let lo = got.iter().cloned().fold(f64::INFINITY, f64::min); + let hi = got.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let span = if hi > lo { hi - lo } else { 1.0 }; + let mut out: Vec<(String, String)> = Vec::new(); + for (_, r) in window { + let (colour, glyph) = match r { + None => (p.bad.clone(), '×'), + Some(v) => { + let frac = (v - lo) / span; + let colour = if frac < 0.5 { + &p.ok + } else if frac < 0.85 { + &p.warn + } else { + &p.bad + }; + (colour.clone(), SPARK[((frac * 7.99) as usize).min(7)]) + } + }; + match out.last_mut() { + Some((was, text)) if *was == colour => text.push(glyph), + _ => out.push((colour, glyph.to_string())), + } + } + out +} + +/// A loss, a recovery or a spike, with the time it happened. +#[derive(Clone)] +struct Event { + at: String, + hue: String, + host: String, + kind: &'static str, + detail: String, +} + +/// How samples sharing one graph column combine. +/// +/// Median by default: latency is right-skewed, so a single spike inside a +/// bucket would drag a mean well above the latency actually experienced +/// most of the time. +fn aggregate(values: &[f64], how: &str) -> f64 { + let mut ordered = values.to_vec(); + ordered.sort_by(f64::total_cmp); + let n = ordered.len(); + if n == 1 { + return ordered[0]; + } + match how { + "mean" => ordered.iter().sum::<f64>() / n as f64, + "min" => ordered[0], + "max" => ordered[n - 1], + "p95" => ordered[(n - 1).min((n as f64 * 0.95) as usize)], + _ if n % 2 == 1 => ordered[n / 2], + _ => (ordered[n / 2 - 1] + ordered[n / 2]) / 2.0, + } +} + +const AGGREGATORS: &[&str] = &["median", "mean", "min", "max", "p95"]; +const INTERVAL_CHOICES: &[f64] = &[0.2, 0.5, 1.0, 2.0, 5.0]; +const COLUMN_CHOICES: &[f64] = &[0.0, 2.0, 5.0, 10.0]; + +/// The next entry after `current`, wrapping. Used by the cycling keys. +fn cycle<T: PartialEq + Copy>(choices: &[T], current: T) -> T { + let at = choices.iter().position(|c| *c == current).unwrap_or(0); + choices[(at + 1) % choices.len()] +} + +/// The round trip out of one ping reply line. +/// +/// Both shapes ping writes are read - `time=12.3 ms` and `time=12.3ms` - +/// and anything else on the line is left alone rather than guessed at. +fn rtt_of(line: &str) -> Option<f64> { + let at = line.find("time=")? + 5; + let rest = &line[at..]; + let end = rest + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(rest.len()); + rest[..end].parse().ok() +} + +fn is_loss(line: &str) -> bool { + line.contains("Unreachable") || line.contains("no answer") || line.contains("Time to live") +} + +/// The address ping resolved the host to, from its first line. +fn ip_of(line: &str) -> Option<String> { + let open = line.find('(')?; + let close = line[open..].find(')')? + open; + let inside = &line[open + 1..close]; + if inside.chars().any(|c| c.is_ascii_digit()) { + Some(inside.to_string()) + } else { + None + } +} + +/// What the cycling keys change, shared with the reader threads. +/// +/// The interval lives here rather than being handed to each thread once, +/// because pressing i has to reach pings that are already running. +#[derive(Default)] +struct Settings { + interval: f64, + seconds_per_column: f64, + aggregate: String, + spike_factor: f64, +} + +/// Keep one ping running per target, forever. +/// +/// Wrapped in its own thread per target and never allowed to end: if ping +/// exits - a name that stopped resolving, a network that went away - the +/// row would otherwise just stop updating, which reads as a quiet link +/// rather than as a broken widget. +fn watch( + host: String, + index: usize, + window: usize, + shared: Arc<Mutex<Vec<Target>>>, + settings: Arc<Mutex<Settings>>, + events: Arc<Mutex<Vec<Event>>>, + hue: String, + label: String, +) { + loop { + let (interval, spike_factor) = match settings.lock() { + Ok(s) => (s.interval, s.spike_factor), + Err(_) => return, + }; + let child = std::process::Command::new("ping") + .args(["-n", "-O", "-i", &interval.to_string(), &host]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => { + let why = format!("ping will not start: {}", e); + if let Ok(mut guard) = shared.lock() { + guard[index].err = why; + guard[index].alive = false; + } + std::thread::sleep(Duration::from_secs(2)); + continue; + } + }; + // It started, so whatever stopped it last time is over. + if let Ok(mut guard) = shared.lock() { + guard[index].err.clear(); + } + if let Ok(mut guard) = shared.lock() { + guard[index].pid = Some(child.id() as i32); + } + let stdout = match child.stdout.take() { + Some(s) => s, + None => continue, + }; + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let stamp = now(); + let mut guard = match shared.lock() { + Ok(g) => g, + Err(_) => return, + }; + let target = &mut guard[index]; + if target.ip.is_empty() { + if let Some(ip) = ip_of(&line) { + target.ip = ip; + } + } + if let Some(rtt) = rtt_of(&line) { + // A spike is worth a line in the log because the median in + // the table will not move for it, and a link that is fine + // except once a minute is a different problem from one that + // is slow. + let st = target.stats(); + if let Some(med) = st.med { + if st.n > 10 && rtt > med * spike_factor { + log(&events, &hue, &label, "SPIKE", + format!("{} (median {})", fmt_ms(Some(rtt)).trim(), + fmt_ms(Some(med)).trim())); + } + } + if let Some(since) = target.down_since.take() { + log(&events, &hue, &label, "UP", + format!("recovered after {:.0}s", stamp - since)); + } + target.alive = true; + target.record(stamp, Some(rtt), window); + } else if is_loss(&line) { + if target.down_since.is_none() { + target.down_since = Some(stamp); + log(&events, &hue, &label, "LOSS", "no reply".into()); + } + target.alive = false; + target.record(stamp, None, window); + } + } + let _ = child.wait(); + let ours = match shared.lock() { + Ok(mut guard) => { + guard[index].pid = None; + let deliberate = guard[index].restarting; + guard[index].restarting = false; + deliberate + } + Err(_) => return, + }; + // We killed it ourselves to apply a new interval; not an outage, and + // it starts again immediately rather than after the retry pause. + if ours { + continue; + } + if let Ok(mut guard) = shared.lock() { + let target = &mut guard[index]; + if target.down_since.is_none() { + target.down_since = Some(now()); + drop(guard); + log(&events, &hue, &label, "DOWN", "ping exited, retrying".into()); + } else { + drop(guard); + } + } + if let Ok(mut guard) = shared.lock() { + guard[index].alive = false; + guard[index].record(now(), None, window); + } + std::thread::sleep(Duration::from_secs(2)); + } +} + +fn log(events: &Arc<Mutex<Vec<Event>>>, hue: &str, host: &str, kind: &'static str, detail: String) { + if let Ok(mut guard) = events.lock() { + guard.push(Event { + at: clock_time(), + hue: hue.to_string(), + host: host.to_string(), + kind, + detail, + }); + let most = 40; + if guard.len() > most { + let drop = guard.len() - most; + guard.drain(..drop); + } + } +} + +/// The time of day, as the machine reckons it. +/// +/// Local rather than UTC: this sits on a wall beside a clock panel showing +/// server time, and a header eight hours out from the pane next to it is +/// read as a broken widget rather than as a different timezone. +fn clock_time() -> String { + chrono::Local::now().format("%H:%M:%S").to_string() +} + +/// Restart every ping so a new interval takes effect at once. +/// +/// SIGTERM rather than waiting for the current one to end: at five seconds +/// a change would otherwise take five seconds to become visible, which +/// reads as the key not having worked. +fn apply_interval(shared: &Arc<Mutex<Vec<Target>>>) { + if let Ok(mut guard) = shared.lock() { + for target in guard.iter_mut() { + if let Some(pid) = target.pid { + target.restarting = true; + if unsafe { libc::kill(pid, libc::SIGTERM) } != 0 { + target.restarting = false; + } + } + } + } +} + +/// A braille cell is two dots wide and four tall, so one character holds +/// eight addressable points. The bit for each is fixed by the encoding. +const BRAILLE: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]; + +/// Plot one series on a dot canvas eight times finer than the cells. +/// +/// Consecutive samples are joined rather than left as marks, which is the +/// difference between a line that reads as a round trip moving and one that +/// reads as specks a row apart. The masks come back per cell instead of as +/// text so that several series can be laid over one another first. +fn braille_canvas( + values: &[Option<f64>], + llo: f64, + lhi: f64, + cols: usize, + rows: usize, +) -> Vec<Vec<u8>> { + let (px_w, px_h) = (cols * 2, rows * 4); + let mut grid = vec![vec![0u8; cols]; rows]; + if values.is_empty() || px_w == 0 || px_h == 0 { + return grid; + } + let vals: Vec<Option<f64>> = values.iter().rev().take(px_w).rev().copied().collect(); + // Newest against the right edge: a target that has answered five times + // shows five samples there, not five stretched across the whole width. + let left = px_w - vals.len(); + let decade = (lhi - llo).max(1e-9); + let point = |i: usize| -> Option<(i64, i64)> { + let v = vals[i]?; + let frac = ((v.max(1e-3).log10() - llo) / decade).clamp(0.0, 1.0); + Some(( + (left + i) as i64, + ((1.0 - frac) * (px_h as f64 - 1.0)).round() as i64, + )) + }; + let dot = |x: i64, y: i64, grid: &mut Vec<Vec<u8>>| { + if x >= 0 && (x as usize) < px_w && y >= 0 && (y as usize) < px_h { + grid[y as usize / 4][x as usize / 2] |= BRAILLE[y as usize % 4][x as usize % 2]; + } + }; + // A single reading is a measurement and gets its dot: unlike netwatch's + // idle zero, there is no value here that means "nothing happened". + if let Some((x, y)) = point(0) { + dot(x, y, &mut grid); + } + for i in 1..vals.len() { + // A column with no reply is a gap, and a gap is not drawn through. + // Joining across one would draw a line where the link was down, + // which is the opposite of what happened. + let (Some((mut x0, mut y0)), Some((x1, y1))) = (point(i - 1), point(i)) else { + if let Some((x, y)) = point(i) { + dot(x, y, &mut grid); + } + continue; + }; + let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs()); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + dot(x0, y0, &mut grid); + if x0 == x1 && y0 == y1 { + break; + } + let twice = 2 * err; + if twice >= dy { + err += dy; + x0 += sx; + } + if twice <= dx { + err += dx; + y0 += sy; + } + } + } + grid +} + +/// Lay the canvases over one another, cell by cell. +/// +/// A braille cell can hold the dots of two traces but only one colour, and +/// there is no honest way to show both: whichever colour the cell takes, the +/// other trace's samples are drawn in a hue that is not theirs. That is a +/// number on screen that is not real, which is the one thing this collection +/// does not do - and it is not hypothetical, it hid a target completely. Two +/// hosts eleven milliseconds apart at 130ms sit 0.036 of a decade apart where +/// a dot row is 0.065, so they contest nearly every cell they occupy, and +/// merging painted the whole of one of them in the other's colour. +/// +/// So a cell belongs to exactly one trace and shows only that trace's dots. +/// Where several want it, ownership advances once per contested cell, which +/// makes a contested stretch read as interleaved dashed lines - each dot its +/// own colour - rather than one solid line belonging to nobody. Traces that +/// never meet are unaffected and stay solid. +/// +/// The turn is counted over contested cells and not taken from the column +/// number, which is the same bug one level in. Indexing by `x` looks fair +/// and is not: when the contested cells all share a parity - which happens +/// as soon as one series has a gap in every other column, and the column +/// width is free-form config - `x % 2` picks the same claimant every time +/// and the other trace is absent from the whole stretch. That is the +/// failure this function exists to prevent, in a narrower form. +fn overlay(layers: &[(String, Vec<Vec<u8>>)], cols: usize, rows: usize) -> Vec<Vec<(String, u8)>> { + let mut cells = vec![vec![(String::new(), 0u8); cols]; rows]; + for y in 0..rows { + // Counts contested cells along this row, so every claimant takes a + // turn no matter which columns the contention lands on. + let mut turn = 0usize; + for x in 0..cols { + let dots = |canvas: &Vec<Vec<u8>>| { + canvas.get(y).and_then(|line| line.get(x)).copied().unwrap_or(0) + }; + let claims: Vec<&(String, Vec<Vec<u8>>)> = + layers.iter().filter(|(_, canvas)| dots(canvas) != 0).collect(); + let Some((colour, canvas)) = claims.get(turn % claims.len().max(1)) else { + continue; + }; + cells[y][x] = ((*colour).clone(), dots(canvas)); + if claims.len() > 1 { + turn += 1; + } + } + } + cells +} + +/// Log-scale plot of every target's round trip. +/// +/// Log because the targets on one screen can differ by two orders of +/// magnitude, and a linear axis renders the near one as a flat line at the +/// bottom. +/// +/// Columns are anchored to a fixed time grid rather than measured backwards +/// from now, so a sample never migrates between columns: the plot steps left +/// exactly once per bucket instead of shuffling as the clock slides. +fn graph( + targets: &[Target], + w: usize, + h: usize, + bucket: f64, + how: &str, + focus: Option<usize>, + p: &Palette, +) -> (Vec<String>, f64) { + let gw = w.saturating_sub(9).max(10); + let gh = h.max(4); + // Two dot columns to a cell, so the chart holds twice the buckets it + // did when each one had a whole character to itself. + let slots = gw * 2; + let newest = (now() / bucket).floor(); + let series: Vec<(usize, Vec<Option<f64>>)> = targets + .iter() + .enumerate() + .map(|(i, t)| { + let mut columns: Vec<Vec<f64>> = vec![Vec::new(); slots]; + for (at, rtt) in &t.samples { + let Some(rtt) = rtt else { continue }; + let age = newest - (at / bucket).floor(); + if age < 0.0 || age >= slots as f64 { + continue; + } + columns[slots - 1 - age as usize].push(*rtt); + } + let values = columns + .into_iter() + .map(|c| if c.is_empty() { None } else { Some(aggregate(&c, how)) }) + .collect(); + (i, values) + }) + .collect(); + let span = bucket * slots as f64; + let seen: Vec<f64> = series + .iter() + .flat_map(|(_, v)| v.iter().flatten()) + .copied() + .collect(); + if seen.is_empty() { + // Nothing to plot has two causes and they are not the same. With no + // targets there is nothing to collect and never will be, so + // "collecting…" is a promise the widget cannot keep - it is the + // pane looking busy over an empty list, which is the shape this + // repo's checks exist to catch. + let why = if targets.is_empty() { + " no hosts configured - set latency.hosts in config.json" + } else { + " collecting…" + }; + return (vec![tc::seg(&[(p.dim.as_str(), why.into())], w - 1)], span); + } + let lo = seen.iter().cloned().fold(f64::INFINITY, f64::min).max(0.05) * 0.8; + let hi = (seen.iter().cloned().fold(0.0f64, f64::max) * 1.25).max(lo * 1.6); + let (llo, lhi) = (lo.log10(), hi.log10()); + + // One canvas per target rather than one shared grid: a braille cell can + // carry the dots of two traces but only one hue, so each series has to + // keep its own until the moment they are laid over one another. + // + // The selected target is laid down last, so where two traces share a + // cell the colour goes to the one being looked at rather than to + // whichever happens to sit lower in the table. The others are mixed + // most of the way to the backdrop - still drawn, because a chart that + // dropped every other target the moment you selected one would be + // answering a different question, but no longer competing. + let mut layers: Vec<(String, Vec<Vec<u8>>)> = Vec::with_capacity(series.len()); + let mut front: Option<(String, Vec<Vec<u8>>)> = None; + for (idx, values) in &series { + let canvas = braille_canvas(values, llo, lhi, gw, gh); + let hue = p.hues[idx % p.hues.len()].clone(); + match focus { + // Nothing selected: every trace at full strength, in table + // order, which is the chart this widget has always drawn. + None => layers.push((hue, canvas)), + Some(at) if at == *idx => front = Some((hue, canvas)), + Some(_) => layers.push((p.faded[idx % p.faded.len()].clone(), canvas)), + } + } + let mut cells = overlay(&layers, gw, gh); + // The focused trace takes its cells outright rather than being merged + // into them. + // + // `overlay` unions the dots and gives the cell to the last writer, which + // is right when every trace is equal: no sample is lost and the colour + // follows the table's order. Under focus it is a lie. Two targets a few + // percent apart share a cell constantly - 138ms and 127ms sit 0.036 of a + // decade apart where a dot row is 0.065 - and merging drew the other + // one's dots in the focused colour, so a flat trace came out two rows + // thick and the second row belonged to a different host. + // + // Replacing the cell hides the faded trace where the two meet. That is + // the right way round: the faded one is the one being pushed back, and + // it stays legible either side, whereas a sample drawn in a colour that + // is not its own is a number on screen that is not real. + if let Some((hue, canvas)) = front { + for (y, line) in canvas.iter().enumerate().take(gh) { + for (x, mask) in line.iter().enumerate().take(gw) { + if *mask != 0 { + cells[y][x] = (hue.clone(), *mask); + } + } + } + } + + let mut out = Vec::new(); + for (y, line) in cells.iter().enumerate() { + let frac = 1.0 - (y as f64 / (gh as f64 - 1.0).max(1.0)); + let value = 10f64.powf(llo + frac * (lhi - llo)); + // Label only the top, middle and bottom: a number on every row is a + // table pretending to be an axis. + let label = if y == 0 || y == gh / 2 || y == gh - 1 { + fmt_ms(Some(value)) + } else { + " ".repeat(7) + }; + let mut parts: Vec<(&str, String)> = + vec![(p.dim.as_str(), label), (p.grid.as_str(), "│".into())]; + for (colour, mask) in line { + parts.push(match mask { + 0 => (p.grid.as_str(), " ".into()), + m => ( + colour.as_str(), + char::from_u32(0x2800 + *m as u32).unwrap_or(' ').to_string(), + ), + }); + } + out.push(tc::seg(&parts, w - 1)); + } + (out, span) +} + +fn main() { + tc::maybe_help(include_str!("latency_help.txt")); + let cfg = tc::load_config("latency"); + let hosts = tc::cfg_strings(&cfg, "hosts", &["1.1.1.1", "8.8.8.8"]); + let window = tc::cfg_usize(&cfg, "window", 600); + let strip: Vec<String> = tc::cfg_strings(&cfg, "strip_suffixes", &[]); + let mut live = Settings { + interval: tc::cfg_f64(&cfg, "interval", 0.5), + seconds_per_column: tc::cfg_f64(&cfg, "seconds_per_column", 0.0), + aggregate: tc::cfg_str(&cfg, "aggregate", "median"), + spike_factor: tc::cfg_f64(&cfg, "spike_factor", 3.0), + }; + + let args: Vec<String> = std::env::args().skip(1).collect(); + let mut named: Vec<String> = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-i" | "--interval" if i + 1 < args.len() => { + live.interval = args[i + 1].parse::<f64>().unwrap_or(0.5).max(0.2); + i += 2; + } + "-c" | "--column-seconds" if i + 1 < args.len() => { + live.seconds_per_column = args[i + 1].parse::<f64>().unwrap_or(0.0).max(0.0); + i += 2; + } + "-g" | "--group" if i + 1 < args.len() => { + if !AGGREGATORS.contains(&args[i + 1].as_str()) { + eprintln!("-g must be one of: {}", AGGREGATORS.join(", ")); + std::process::exit(2); + } + live.aggregate = args[i + 1].clone(); + i += 2; + } + other if !other.starts_with('-') => { + named.push(other.to_string()); + i += 1; + } + _ => i += 1, + } + } + let hosts = if named.is_empty() { hosts } else { named }; + + let absent = tc::missing(&["ping"]); + if !absent.is_empty() { + cannot_start(&absent); + return; + } + + let p = palette(); + let targets: Vec<Target> = hosts + .iter() + .map(|h| Target { + label: label_for(h, &strip), + ..Default::default() + }) + .collect(); + let labels: Vec<String> = targets.iter().map(|t| t.label.clone()).collect(); + // Fixed for the life of the run: one poll thread per host, and the + // list never grows or shrinks, so the cursor can be reasoned about + // without holding the lock to count rows. + let count = hosts.len(); + let shared = Arc::new(Mutex::new(targets)); + let settings = Arc::new(Mutex::new(live)); + let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new())); + for (index, host) in hosts.iter().enumerate() { + let shared = Arc::clone(&shared); + let settings = Arc::clone(&settings); + let events = Arc::clone(&events); + let host = host.clone(); + let hue = p.hues[index % p.hues.len()].clone(); + let label = labels[index].clone(); + std::thread::spawn(move || { + watch(host, index, window, shared, settings, events, hue, label) + }); + } + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + // Which target the chart brings to the front, if any. Starts at none, + // so the chart opens saying what it has always said - every target at + // equal weight - and focus is something you ask for rather than a state + // you have to escape from. Clamped against the list when the frame is + // drawn rather than when the key is pressed, because targets are only + // known to the poll threads. + let mut selected: Option<usize> = None; + loop { + for key in keyboard.poll() { + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + // No selection is the position above the first row and + // below the last one, so walking off either end lands there + // and walking off it again comes in at the other. Focus is + // then something you can leave the way you entered it, + // rather than a state with only one door. + "up" | "k" | "K" => { + selected = match selected { + None => count.checked_sub(1), + Some(0) => None, + Some(at) => Some(at - 1), + } + } + "down" | "j" | "J" => { + selected = match selected { + None if count > 0 => Some(0), + None => None, + Some(at) if at + 1 >= count => None, + Some(at) => Some(at + 1), + } + } + // Back to every target drawn alike. + "esc" => selected = None, + "i" | "I" => { + if let Ok(mut s) = settings.lock() { + s.interval = cycle(INTERVAL_CHOICES, s.interval); + } + apply_interval(&shared); + } + "g" | "G" => { + if let Ok(mut s) = settings.lock() { + let at = AGGREGATORS + .iter() + .position(|a| *a == s.aggregate) + .unwrap_or(0); + s.aggregate = AGGREGATORS[(at + 1) % AGGREGATORS.len()].to_string(); + } + } + "c" | "C" => { + if let Ok(mut s) = settings.lock() { + s.seconds_per_column = cycle(COLUMN_CHOICES, s.seconds_per_column); + } + } + _ => {} + } + } + let (w, h) = tc::size(); + let snapshot: Vec<Target> = match shared.lock() { + Ok(g) => g.clone(), + Err(_) => return, + }; + if let Some(at) = selected { + selected = if snapshot.is_empty() { + None + } else { + Some(at.min(snapshot.len() - 1)) + }; + } + let (interval, per_column, how) = match settings.lock() { + Ok(s) => (s.interval, s.seconds_per_column, s.aggregate.clone()), + Err(_) => return, + }; + // Zero means one bucket per ping, which is the finest motion the + // grid allows; anything larger trades that for a longer history. + let bucket = if per_column > 0.0 { per_column } else { interval }; + + let mut rows = vec![tc::title("network latency monitor", w, &p.head)]; + rows.push(tc::seg( + &[ + ( + p.dim.as_str(), + format!(" {} targets · {:.1}s interval · ", snapshot.len(), interval), + ), + (p.txt.as_str(), clock_time()), + ( + p.dim.as_str(), + if bucket <= interval { + " · 1 ping/column".to_string() + } else { + format!(" · {} of {}s blocks", how, bucket) + }, + ), + ], + w - 1, + )); + rows.push(String::new()); + + // The columns are dropped from the right as the pane narrows rather + // than clipped, because half a number is worse than none. + let wide = w >= 72; + let show_med = w >= 80; + let name_w = 22usize; + rows.push(tc::seg( + &[( + p.lbl.as_str(), + format!( + " {} {:>7} {:>7}{} {:>7} {:>7} {:>7} {:>6}", + tc::pad("HOST", name_w), + "NOW", + "AVG", + if show_med { " MEDIAN" } else { "" }, + "MIN", + "MAX", + "JITTER", + "LOSS" + ), + )], + w - 1, + )); + for (i, t) in snapshot.iter().enumerate() { + let st = t.stats(); + let here = selected == Some(i); + // The selected row is tinted rather than marked, so the thing + // that says "this one" in the table is the same thing that says + // it in the chart: one target at full strength, the rest behind. + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let raw_hue = p.hues[i % p.hues.len()].clone(); + let loss_c = if st.loss == 0.0 { + &p.ok + } else if st.loss < 5.0 { + &p.warn + } else { + &p.bad + }; + // A tint is a background escape and has to come before every + // foreground on the row, or the colour that follows it resets + // the background and the highlight stops halfway across. + let tinted = |colour: &str| format!("{}{}", tint, colour); + // MIN and MAX are drawn in `dim`, which does not clear AA on the + // tint. On the selected row they get the lighter one. + let dim = if here { &p.dim_lit } else { &p.dim }; + // Owned colours rather than borrowed: the row is assembled + // before it is measured, so a `&format!(...)` temporary would not + // outlive the vector it was put in. + let mut cells: Vec<(String, String)> = vec![ + // No status dot. latency.py draws ● or ○ here, but it says + // exactly what NOW says one column to the right and at the + // same instant - a target that is not answering has no round + // trip to print - so it was six glyphs of permanent green + // reporting something already on screen. Losing it also lets + // the columns sit under their own headings, which the dot's + // uncounted cell prevented. + // + // The cell the header spends on a leading space is the marker + // column. A tint alone says "this row is not like the others" + // without saying which way, and it is gone entirely on a + // terminal that will not paint one - so the mark is a solid + // bar rather than a glyph, because the tint cannot be made + // louder: (28,44,62) is already as bright as it goes before + // `bad` red drops under AA on it, measured at 4.80 against a + // floor of 4.5. + // + // ▐ is East Asian Neutral, so it is one cell wherever it + // renders. ▌ and █ read better but are Ambiguous, and this + // row is otherwise all ASCII - one Ambiguous cell would shift + // the selected row and nothing else. + (tinted(&raw_hue), "▐".to_string()), + // The chip is a colour, not a letter, and reads as part of + // the name when it touches it. The header spends the same + // cell so the columns still sit under their own headings. + (tint.clone(), " ".to_string()), + // Grey until this is the row being looked at, then white - + // which is how link tells its selected session apart, and + // the reason its list reads at a glance. The hue cannot do + // that job: fading a hue far enough to be a contrast takes + // it under AA long before it is a difference the eye + // catches, measured at 3.86 by a fade of 0.30 and only 1.73 + // away from the full colour. So the hue moves to the chip, + // which is what link puts its glyph in, and the name is free + // to swing between two colours that are both readable. + ( + tinted(if t.err.is_empty() { + if here { &p.txt } else { &p.dim_lit } + } else { + &p.bad + }), + tc::pad(&t.label, name_w), + ), + // Red when there is no round trip to report, which is the + // whole of what the status dot used to say. + ( + tinted(if st.now.is_some() { &p.txt } else { &p.bad }), + format!(" {}", fmt_ms(st.now)), + ), + (tinted(&p.txt), format!(" {}", fmt_ms(st.avg))), + ( + tinted(&p.ok), + if show_med { + format!(" {}", fmt_ms(st.med)) + } else { + String::new() + }, + ), + (tinted(dim), format!(" {}", fmt_ms(st.min))), + (tinted(dim), format!(" {}", fmt_ms(st.max))), + (tinted(&p.txt), format!(" {}", fmt_ms(st.jit))), + (tinted(loss_c), format!(" {:>5.1}%", st.loss)), + // The reason, when there is one. It sits after the figures + // rather than replacing them: the numbers from before it + // broke are still true, and still the last thing this + // target was known to be doing. + ( + tinted(&p.bad), + if t.err.is_empty() { + String::new() + } else { + format!(" ! {}", t.err) + }, + ), + ]; + // Carry the tint to the edge. Left ragged it stops wherever the + // last number happens to end, and a highlight that stops short + // reads as a smudge rather than as a bar across the row. + if here { + let used: usize = cells.iter().map(|(_, t)| t.chars().count()).sum(); + cells.push((tint.clone(), " ".repeat((w - 1).saturating_sub(used)))); + } + let parts: Vec<(&str, String)> = + cells.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&parts, w - 1)); + if wide && !t.samples.is_empty() { + let mut line: Vec<(&str, String)> = vec![(p.dim.as_str(), " ".into())]; + let spark = sparkline(&t.samples, w.saturating_sub(6), &p); + for (colour, text) in &spark { + line.push((colour.as_str(), text.clone())); + } + rows.push(tc::seg(&line, w - 1)); + } + } + rows.push(String::new()); + + // The keys live along the bottom, so they are measured before the + // chart and the log are sized and the rows they occupy are taken out + // of what is left. Sizing the body to the whole pane and appending + // them afterwards is how a footer ends up pushed off the bottom of + // the screen it documents. `pack_hints` wraps without splitting one, + // because half a hint teaches a key that does not exist. + let mut hints: Vec<Vec<(&str, String)>> = vec![vec![ + (p.head.as_str(), "↑↓".into()), + (p.dim.as_str(), " focus".into()), + ]]; + // Offered only once there is a selection to clear. An empty hint + // would still cost `pack_hints` a separator and leave a gap in the + // footer where a key used to be. + if selected.is_some() { + hints.push(vec![(p.dim.as_str(), "[esc] clear focus".into())]); + } + hints.extend([ + vec![(p.dim.as_str(), "[i]nterval".into())], + vec![(p.dim.as_str(), "[g]roup".into())], + vec![(p.dim.as_str(), "[c]olumns".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]); + let foot: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + let body_h = h.saturating_sub(foot.len()); + + // The log only earns its space on a tall pane: on a short one the + // chart is the thing worth keeping. + let log_h = if body_h.saturating_sub(rows.len()) > 20 { 7 } else { 0 }; + let gh = body_h.saturating_sub(rows.len() + log_h + 4).max(4); + let (chart, span) = graph(&snapshot, w, gh, bucket, &how, selected, &p); + let drawn = chart.len(); + rows.extend(chart); + if drawn > 1 { + let gw = w.saturating_sub(9).max(10); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ".repeat(7)), + (p.grid.as_str(), format!("└{}", "─".repeat(gw))), + ], + w - 1, + )); + let ago = format!("{}s ago", span as i64); + let ago = if ago.chars().count() + 4 > gw { + String::new() + } else { + ago + }; + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!("{:8}{}", "", ago)), + ( + p.dim.as_str(), + " ".repeat(gw.saturating_sub(ago.chars().count() + 3)), + ), + (p.dim.as_str(), "now".into()), + ], + w - 1, + )); + } + rows.push(String::new()); + + if log_h > 0 { + rows.push(tc::seg(&[(p.dim.as_str(), " ── EVENTS ──".into())], w - 1)); + let recent: Vec<Event> = match events.lock() { + Ok(g) => g.iter().rev().take(log_h - 1).rev().cloned().collect(), + Err(_) => Vec::new(), + }; + if recent.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " (no loss or spikes recorded)".into())], + w - 1, + )); + } + for event in &recent { + let kind_c = match event.kind { + "LOSS" | "DOWN" => &p.bad, + "SPIKE" => &p.warn, + _ => &p.ok, + }; + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ", event.at)), + (kind_c.as_str(), format!("{:<6}", event.kind)), + (event.hue.as_str(), tc::pad(&event.host, 22)), + (p.dim.as_str(), event.detail.clone()), + ], + w - 1, + )); + } + } + + while rows.len() < body_h { + rows.push(String::new()); + } + rows.truncate(body_h); + rows.extend(foot); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(300)); + } +} + +/// A host as a person would say it, with the noise stripped off the end. +fn label_for(host: &str, strip: &[String]) -> String { + let mut label = host.to_string(); + for suffix in strip { + if let Some(base) = label.strip_suffix(suffix.as_str()) { + label = base.to_string(); + break; + } + } + label +} + +/// Draw the reason and wait, rather than exiting. +fn cannot_start(needed: &[String]) { + let bad = tc::rgb(255, 100, 110); + let dim = tc::rgb(127, 147, 172); + let txt = tc::rgb(225, 235, 245); + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + loop { + for key in keyboard.poll() { + if key == "q" || key == "Q" { + keyboard.restore(); + tc::restore_screen(); + return; + } + } + let (w, h) = tc::size(); + let mut rows = vec![tc::title("latency", w, &bad), String::new()]; + rows.push(tc::seg( + &[ + (bad.as_str(), " cannot start · ".into()), + (txt.as_str(), format!("needs {}", needed.join(", "))), + ], + w - 1, + )); + rows.push(String::new()); + for line in [ + "Every figure here comes from ping: this widget times replies,", + "it does not send packets itself. With no ping there is nothing", + "to time and nothing to draw.", + ] { + rows.push(tc::seg(&[(dim.as_str(), format!(" {}", line))], w - 1)); + } + rows.push(String::new()); + rows.push(tc::seg( + &[ + (dim.as_str(), " try: ".into()), + (txt.as_str(), "apt install iputils-ping".into()), + ], + w - 1, + )); + while rows.len() < h - 1 { + rows.push(String::new()); + } + rows.push(tc::seg(&[(dim.as_str(), " [q]uit".into())], w - 1)); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(200)); + } +} + +struct Palette { + ok: String, + warn: String, + bad: String, + dim: String, + grid: String, + txt: String, + lbl: String, + /// The readable dim, used wherever `dim` would fail: on the selected + /// row's tint, where (70,100,120) measures 2.27 against AA's 4.5, and + /// for every host name, which is grey until its row is the one selected. + /// This clears AA on the tint at 4.83 and on the backdrop at 6.18. + dim_lit: String, + head: String, + hues: Vec<String>, + /// The same nine, mixed toward the backdrop, for traces that are not + /// the one selected. + faded: Vec<String>, +} + +fn palette() -> Palette { + Palette { + ok: tc::rgb(110, 255, 170), + warn: tc::rgb(255, 200, 90), + bad: tc::rgb(255, 95, 105), + dim: tc::rgb(70, 100, 120), + grid: tc::rgb(38, 58, 74), + txt: tc::rgb(215, 235, 250), + lbl: tc::rgb(120, 170, 200), + dim_lit: tc::rgb(120, 155, 180), + head: tc::rgb(90, 220, 255), + // latency.py's own nine, not the six the other widgets share: the + // traces are told apart by hue alone now that the glyphs are gone, + // so more targets than six needs more than six colours. + hues: HUES.iter().map(|c| tc::rgb(c.0, c.1, c.2)).collect(), + faded: HUES.iter().map(|c| tc::mix(*c, BACKDROP, FADE)).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_target_that_never_answers_does_not_grow_for_ever() { + // The retry path - `ping` exited, sleep two seconds, try again - + // records a loss each time round. Its retention used to sit in the + // loop over `ping`'s output, which a target that exits immediately + // never enters, so an unreachable host grew the vector by a sample + // every two seconds for as long as the pane was up and made + // `stats()` reread all of it every frame. + let window = 8; + let mut t = Target::default(); + for i in 0..500 { + t.record(i as f64, None, window); + assert!(t.samples.len() <= window, "grew to {} at {}", t.samples.len(), i); + } + assert_eq!(t.samples.len(), window); + // What is kept is the newest, not the oldest. + assert_eq!(t.samples.first().map(|(at, _)| *at), Some(492.0)); + assert_eq!(t.samples.last().map(|(at, _)| *at), Some(499.0)); + // Answers are held to the same rule. + let mut t = Target::default(); + for i in 0..500 { + t.record(i as f64, Some(i as f64), window); + } + assert_eq!(t.samples.len(), window); + } + + #[test] + fn a_reply_gives_up_its_round_trip() { + let line = "64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=12.3 ms"; + assert_eq!(rtt_of(line), Some(12.3)); + // The other shape ping writes, without the space. + assert_eq!(rtt_of("... time=0.45ms"), Some(0.45)); + assert_eq!(rtt_of("PING example (1.2.3.4) 56 bytes"), None); + } + + #[test] + fn losses_are_recognised_but_not_timed() { + assert!(is_loss("From 10.0.0.1 icmp_seq=2 Destination Net Unreachable")); + assert!(is_loss("no answer yet for icmp_seq=3")); + assert!(!is_loss("64 bytes from 1.1.1.1: time=1 ms")); + } + + #[test] + fn the_resolved_address_is_taken_from_the_header() { + assert_eq!( + ip_of("PING one.one.one.one (1.1.1.1) 56(84) bytes of data."), + Some("1.1.1.1".into()) + ); + assert_eq!(ip_of("64 bytes from host: time=1 ms"), None); + } + + #[test] + fn a_spike_belongs_in_max_and_not_in_the_middle() { + let mut t = Target::default(); + // Nine steady samples and one wild spike. The median and the + // typical round trip are unmoved by it; max is where it shows. + for v in [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 400.0] { + t.samples.push((0.0, Some(v))); + } + let st = t.stats(); + assert_eq!(st.med, Some(10.0)); + assert_eq!(st.max, Some(400.0)); + assert_eq!(st.min, Some(10.0)); + assert_eq!(st.now, Some(400.0)); + } + + #[test] + fn jitter_is_the_gap_between_one_reply_and_the_next() { + let mut t = Target::default(); + // Ten and twenty alternating: every consecutive gap is 10ms, so + // that is the jitter - even though every sample sits 5ms from the + // mean, which is what a deviation would have reported. + for v in [10.0, 20.0, 10.0, 20.0, 10.0] { + t.samples.push((0.0, Some(v))); + } + assert_eq!(t.stats().jit, Some(10.0)); + // A steady link has none, and one sample cannot have any. + let mut steady = Target::default(); + for _ in 0..4 { + steady.samples.push((0.0, Some(30.0))); + } + assert_eq!(steady.stats().jit, Some(0.0)); + } + + #[test] + fn loss_counts_the_unanswered() { + let mut t = Target::default(); + t.samples.push((0.0, Some(10.0))); + t.samples.push((0.0, None)); + t.samples.push((0.0, Some(12.0))); + t.samples.push((0.0, None)); + assert_eq!(t.stats().loss, 50.0); + // Nothing back at all is total loss, not an absent reading. + let mut silent = Target::default(); + silent.samples.push((0.0, None)); + assert_eq!(silent.stats().loss, 100.0); + assert_eq!(silent.stats().med, None); + } + + #[test] + fn milliseconds_keep_their_column_width() { + // Seven cells whatever the value, or the columns shift under the + // headings as a link speeds up. + for value in [Some(123.4), Some(12.34), Some(1.234), Some(0.21), None] { + assert_eq!(fmt_ms(value).chars().count(), 7, "{:?}", value); + } + assert_eq!(fmt_ms(Some(123.4)), "123.4ms"); + assert_eq!(fmt_ms(Some(12.34)), "12.34ms"); + // Below a millisecond it changes unit rather than losing the value. + assert_eq!(fmt_ms(Some(0.21)), " 210µs"); + assert_eq!(fmt_ms(None), " -- "); + } + + #[test] + fn a_bucket_keeps_the_typical_not_the_extreme() { + let block = [10.0, 10.0, 10.0, 10.0, 400.0]; + // Median by default, because latency is right-skewed and one spike + // in a bucket would drag a mean well above what the link felt like. + assert_eq!(aggregate(&block, "median"), 10.0); + assert_eq!(aggregate(&block, "min"), 10.0); + assert_eq!(aggregate(&block, "max"), 400.0); + assert_eq!(aggregate(&block, "mean"), 88.0); + assert_eq!(aggregate(&block, "p95"), 400.0); + // An even count takes the middle of the two middles. + assert_eq!(aggregate(&[10.0, 20.0], "median"), 15.0); + assert_eq!(aggregate(&[7.0], "mean"), 7.0); + } + + #[test] + fn the_cycling_keys_wrap() { + assert_eq!(cycle(INTERVAL_CHOICES, 0.5), 1.0); + assert_eq!(cycle(INTERVAL_CHOICES, 5.0), 0.2); + // A value that is not one of the choices starts from the first. + assert_eq!(cycle(INTERVAL_CHOICES, 3.3), 0.5); + assert_eq!(cycle(COLUMN_CHOICES, 10.0), 0.0); + } + + #[test] + fn a_gap_in_the_data_is_not_drawn_through() { + // Two readings with a lost bucket between them. Joining across it + // would draw a line where the link was down. + let values = [Some(10.0), None, Some(10.0)]; + let grid = braille_canvas(&values, 1.0, 2.0, 3, 2); + let occupied: Vec<bool> = (0..6) + .map(|x| grid.iter().any(|row| row[x / 2] & column_mask(x % 2) != 0)) + .collect(); + // Six dot columns for three cells, and three values, so they sit in + // the last three: a reading, the lost bucket, a reading. + assert!(occupied[3] && occupied[5], "the readings are missing"); + assert!(!occupied[4], "something was drawn across the gap"); + assert!( + !occupied[..3].iter().any(|hit| *hit), + "the empty left of the axis was painted" + ); + } + + /// Every dot bit in one column of a braille cell. + fn column_mask(x: usize) -> u8 { + BRAILLE.iter().fold(0u8, |acc, row| acc | row[x]) + } + + #[test] + fn a_suffix_is_stripped_from_the_label() { + let strip = vec![".example.internal".to_string()]; + assert_eq!(label_for("box.example.internal", &strip), "box"); + assert_eq!(label_for("1.1.1.1", &strip), "1.1.1.1"); + } + + #[test] + fn a_rising_series_climbs_the_canvas() { + // Eight samples across four cells - two dots each - from the bottom + // of the decade the axis covers to the top of it. + let values: Vec<Option<f64>> = (0..8) + .map(|i| Some(10f64.powf(1.0 + i as f64 / 7.0))) + .collect(); + let grid = braille_canvas(&values, 1.0, 2.0, 4, 4); + let highest: Vec<usize> = (0..4) + .map(|x| { + grid.iter() + .position(|row| row[x] != 0) + .expect("every column carries part of the trace") + }) + .collect(); + // Row zero is the top of the canvas, so climbing counts down. + assert_eq!(highest.first(), Some(&3)); + assert_eq!(highest.last(), Some(&0)); + assert!(highest.windows(2).all(|p| p[0] >= p[1]), "{:?}", highest); + } + + #[test] + fn a_contested_cell_belongs_to_one_trace_and_shares_the_run() { + // Two flat traces, one at the top of the decade and one at the + // bottom, both drawn across all four cells. This used to assert the + // union of their dots in the later trace's colour, and that was the + // bug: on a real chart two hosts a few percent apart contest every + // cell, so one of them was drawn entirely in the other's hue and + // vanished from the chart as a distinct line. + let high = [Some(10.0); 8]; + let low = [Some(1.0); 8]; + let top = braille_canvas(&high, 0.0, 1.0, 4, 1); + let bottom = braille_canvas(&low, 0.0, 1.0, 4, 1); + assert!(top[0][0] != 0 && bottom[0][0] != 0, "both must contest"); + let cells = overlay( + &[ + ("first".to_string(), top.clone()), + ("second".to_string(), bottom.clone()), + ], + 4, + 1, + ); + for x in 0..4 { + let (whose, mask) = &cells[0][x]; + // Never the union: a cell shows one trace's samples, so no dot + // is ever painted in a colour that is not its own. + let mine = if whose == "first" { top[0][x] } else { bottom[0][x] }; + assert_eq!(*mask, mine, "column {} carries the other trace's dots", x); + assert_ne!(*mask, top[0][x] | bottom[0][x], "column {} merged", x); + } + // Ownership advances with the column, so a contested stretch shows + // both traces as interleaved dashes rather than hiding one. + // The property rather than the phase: the run is shared, so neither + // trace is missing from it. Pinning the exact alternation here is + // what let the parity starvation through - it asserted the mechanism + // and called that the contract. + let owners: Vec<&str> = (0..4).map(|x| cells[0][x].0.as_str()).collect(); + assert!(owners.contains(&"first"), "{:?}", owners); + assert!(owners.contains(&"second"), "{:?}", owners); + } + + #[test] + fn no_trace_is_starved_by_where_the_contention_falls() { + // Two traces that meet only in even-numbered cells, which is what a + // series with a gap in every other column produces - and the column + // width is free-form config, so it is reachable rather than + // theoretical. Ownership used to be `x % claims.len()`, so every + // contested cell shared a parity, the modulus picked the same + // claimant every time, and the other trace was absent from the whole + // stretch. That is the failure this function exists to prevent, one + // level in. + let a = vec![vec![0b0000_0001u8, 0, 0b0000_0001, 0]]; + let b = vec![vec![0b0100_0000u8, 0, 0b0100_0000, 0]]; + let cells = overlay(&[("first".to_string(), a), ("second".to_string(), b)], 4, 1); + let owners: Vec<&str> = (0..4) + .filter(|x| cells[0][*x].1 != 0) + .map(|x| cells[0][x].0.as_str()) + .collect(); + assert_eq!(owners.len(), 2, "both contested cells should be drawn"); + // The property, not the phase. Which of the two takes the first cell + // is an implementation detail; that neither vanishes is not. + assert!(owners.contains(&"first"), "{:?}", owners); + assert!(owners.contains(&"second"), "{:?}", owners); + } + +} diff --git a/widgets/src/bin/latency_help.txt b/widgets/src/bin/latency_help.txt new file mode 100644 index 0000000..a2b5ca6 --- /dev/null +++ b/widgets/src/bin/latency_help.txt @@ -0,0 +1,34 @@ +Multi-target latency monitor. + +Continuously pings every target, and shows per-target statistics, a per-target +sparkline, a shared log-scale time graph, and a log of loss/spike events. + + latency [-i SECONDS] [-c SECONDS] [host ...] + +Keys while running: up/down (or k/j) picks the target the graph brings to the +front - up from the first target and down from the last put it back to +drawing every target alike, as does esc - i cycles the ping +interval (0.2/0.5/1/2/5s, applied to running pings immediately), g cycles the +column aggregation, c cycles seconds per graph column, q quits. + +-i sets the ping interval. -g picks how samples sharing a column combine +(median, mean, min, max, p95; median by default, because latency is +right-skewed and a mean lets one spike misrepresent the whole block). +-c sets how many seconds each graph column covers; +the default of one column per ping gives the smoothest motion, while a larger +value trades that for a longer visible history. + +Traffic cost: one 98-byte frame each way per target per interval. At the 1.0s +default with 4 targets that is ~0.8 KB/s (~2.8 MB/hour). + +Measures THIS host -> each target. It cannot measure target-to-target legs; +that needs a probe running on the far end. + +The shared graph is drawn on a braille dot canvas rather than one character +per sample, so it holds twice the history and reads as a line rather than a +column of marks. Targets share the one chart, so the selected row is drawn +last and at full strength while the rest are mixed toward the background: +where two traces cross, the colour goes to the one being looked at rather +than to whichever sits lower in the table. The others stay drawn rather than +hidden, because a chart that showed one target at a time would answer a +different question. The per-target sparkline is still one character per ping. diff --git a/widgets/src/bin/linear.rs b/widgets/src/bin/linear.rs new file mode 100644 index 0000000..ca0122a --- /dev/null +++ b/widgets/src/bin/linear.rs @@ -0,0 +1,3709 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! How the work is moving, across every Linear team. +//! +//! A port of linear.py. Linear has no totalCount on its connections, so +//! anything counted here is walked a page at a time; only the fields the +//! screen actually shows are asked for, because complexity is charged per +//! property and the page count is what costs. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::{NaiveDateTime, Utc}; +use toys_core as tc; + +const API: &str = "https://api.linear.app/graphql"; +/// Linear's maximum page size. +const PAGE: usize = 250; +/// Pages per query, so one huge team cannot spin forever. +const PAGE_CAP: usize = 12; +const WINDOWS: &[i64] = &[7, 14, 30, 60, 90]; +const SETTLE_FRAMES: usize = 8; +/// Tail of a cycle's history that counts as "lately". +const CHURN_DAYS: usize = 6; +const STATE_ORDER: &[&str] = &["triage", "backlog", "unstarted", "started"]; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// A Linear personal API key, from config.json or the environment. +fn token(cfg: &serde_json::Value) -> (String, &'static str) { + let from_config = tc::cfg_str(cfg, "token", ""); + if !from_config.is_empty() { + return (from_config, "config"); + } + let name = tc::cfg_str(cfg, "token_env", "LINEAR_API_KEY"); + let name = if name.is_empty() { "LINEAR_API_KEY".into() } else { name }; + match std::env::var(&name) { + Ok(value) if !value.is_empty() => (value, "env"), + _ => (String::new(), "missing"), + } +} + +/// What the API says is left of this hour's allowance. +#[derive(Clone, Copy, Default)] +struct Quota { + requests: Option<i64>, +} + +fn graphql( + query: &str, + tok: &str, + variables: serde_json::Value, + quota: &Arc<Mutex<Quota>>, +) -> Result<serde_json::Value, String> { + let body = serde_json::json!({ "query": query, "variables": variables }).to_string(); + let (text, headers) = tc::post_json( + API, + &[ + ("Authorization", tok), + ("Content-Type", "application/json"), + ("User-Agent", "terminal-toys"), + ], + &body, + 30, + )?; + for (name, value) in &headers { + if name == "x-ratelimit-requests-remaining" { + if let Ok(left) = value.parse() { + if let Ok(mut guard) = quota.lock() { + guard.requests = Some(left); + } + } + } + } + let data: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; + if let Some(first) = data["errors"].as_array().and_then(|a| a.first()) { + return Err(first["message"] + .as_str() + .unwrap_or("") + .chars() + .take(80) + .collect()); + } + Ok(data["data"].clone()) +} + +/// Follow pageInfo to the end, or to PAGE_CAP, and return every node. +/// +/// The bool says the cap was hit, so the screen can mark the count as a +/// floor rather than reporting a truncated total as a total. +fn pages( + tok: &str, + query: &str, + path: &[&str], + variables: &serde_json::Value, + quota: &Arc<Mutex<Quota>>, +) -> Result<(Vec<serde_json::Value>, bool), String> { + let mut out = Vec::new(); + let mut cursor = serde_json::Value::Null; + for _ in 0..PAGE_CAP { + let mut v = variables.clone(); + v["after"] = cursor.clone(); + let mut conn = graphql(query, tok, v, quota)?; + for step in path { + conn = conn[*step].clone(); + } + for node in conn["nodes"].as_array().into_iter().flatten() { + out.push(node.clone()); + } + if !conn["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false) { + return Ok((out, false)); + } + cursor = conn["pageInfo"]["endCursor"].clone(); + } + Ok((out, true)) +} + +fn open_query() -> String { + format!( + r#" +query($after: String) {{ + issues(first: {}, after: $after, + filter: {{ state: {{ type: {{ nin: ["completed", "canceled", + "duplicate"] }} }} }}) {{ + nodes {{ identifier title url estimate startedAt createdAt + state {{ type }} team {{ key }} project {{ id }} cycle {{ id }} }} + pageInfo {{ hasNextPage endCursor }} + }} +}}"#, + PAGE + ) +} + +fn created_query() -> String { + format!( + r#" +query($after: String, $since: DateTimeOrDuration!) {{ + issues(first: {}, after: $after, filter: {{ createdAt: {{ gte: $since }} }}) {{ + nodes {{ createdAt team {{ key }} }} + pageInfo {{ hasNextPage endCursor }} + }} +}}"#, + PAGE + ) +} + +fn done_query() -> String { + format!( + r#" +query($after: String, $since: DateTimeOrDuration!) {{ + issues(first: {}, after: $after, filter: {{ completedAt: {{ gte: $since }} }}) {{ + nodes {{ identifier completedAt startedAt createdAt team {{ key }} }} + pageInfo {{ hasNextPage endCursor }} + }} +}}"#, + PAGE + ) +} + +/// The running cycles, walked to the end. +/// +/// Fifty is more than most workspaces have running at once - which is +/// exactly why the fifty-first used to go missing without a word. Walking +/// costs nothing while they fit one page: `pages` stops the moment Linear +/// says there is no next one, so the common case is the one request it +/// always was. +const CYCLES_QUERY: &str = r#" +query($after: String) { + cycles(first: 50, after: $after, filter: { isActive: { eq: true } }) { + nodes { + id name number startsAt endsAt progress + issueCountHistory completedIssueCountHistory + scopeHistory completedScopeHistory + team { key name } + } + pageInfo { hasNextPage endCursor } + } +}"#; + +/// Every project in the workspace, with the teams that own it. +/// +/// One request covers all of them - there are dozens, not thousands - so a +/// team's projects are already in hand when its screen opens, rather than +/// arriving a request later while the screen shows nothing. +const PROJECTS_QUERY: &str = r#" +query($after: String) { + projects(first: 100, after: $after) { + nodes { + id name progress targetDate + status { name type } + lead { name } + teams(first: 20) { nodes { key } } + } + pageInfo { hasNextPage endCursor } + } +}"#; + +/// One project in full, asked for only when its screen is opened. +/// +/// The board's own pass carries what a list needs - name, status, progress, +/// a target date. The rest is here: the burn-up, the milestones, who is on +/// it, what it says it is for. Fetching that for every project in the +/// workspace every two minutes would be paying, continuously, for screens +/// nobody has opened. +const PROJECT_QUERY: &str = r#" +query($id: String!) { + project(id: $id) { + id name url description startedAt completedAt + scopeHistory completedScopeHistory + issueCountHistory completedIssueCountHistory + members(first: 20) { nodes { name } pageInfo { hasNextPage } } + projectMilestones(first: 25) { + nodes { name targetDate progress } + pageInfo { hasNextPage } + } + initiatives(first: 5) { nodes { name } pageInfo { hasNextPage } } + } +}"#; + +/// Every team, walked to the end. +/// +/// This is the list every other figure on the board is filtered through, so +/// a team missing from it takes its issues, its cycles and its projects with +/// it - and the board would have said nothing. It asked for `hasNextPage` +/// and never read it. +const TEAMS_QUERY: &str = r#" +query($after: String) { + teams(first: 100, after: $after) { + nodes { key name } + pageInfo { hasNextPage endCursor } + } +}"#; + +fn text(value: &serde_json::Value, key: &str) -> String { + value[key].as_str().unwrap_or("").to_string() +} + +/// The calendar day of an ISO timestamp, as Linear returns them. +fn day(ts: &str) -> String { + ts.chars().take(10).collect() +} + +fn parse(ts: &str) -> Option<NaiveDateTime> { + // By characters, not bytes. len() and [..19] are both byte operations, + // so a timestamp with any multibyte character in its first nineteen + // bytes used to panic on a character boundary rather than decline to + // parse - in a poll thread, on data from a server. + let head: String = ts.chars().take(19).collect(); + if head.chars().count() < 19 { + return None; + } + NaiveDateTime::parse_from_str(&head, "%Y-%m-%dT%H:%M:%S").ok() +} + +fn hours_since(from: Option<NaiveDateTime>, to: Option<NaiveDateTime>) -> Option<f64> { + let (from, to) = (from?, to?); + Some((to - from).num_seconds() as f64 / 3600.0) +} + +fn ago(t: f64) -> String { + if t <= 0.0 { + return "--".into(); + } + let s = (now() - t) as i64; + if s < 60 { + format!("{}s", s) + } else if s < 3600 { + format!("{}m", s / 60) + } else { + format!("{}h", s / 3600) + } +} + +/// A span at whatever unit keeps it readable. +/// +/// Rolls over to years because these figures reach them: an issue open for +/// "1021.6d" is arithmetic, one open for "2.8y" is a decision. +fn dur(hours: Option<f64>) -> String { + let Some(h) = hours else { + return "--".into(); + }; + if h < 1.0 { + return format!("{}m", ((h * 60.0) as i64).max(1)); + } + if h < 48.0 { + return format!("{:.1}h", h); + } + let days = h / 24.0; + if days < 365.0 { + format!("{:.1}d", days) + } else { + format!("{:.1}y", days / 365.0) + } +} + +fn median(xs: &[f64]) -> Option<f64> { + if xs.is_empty() { + return None; + } + let mut s = xs.to_vec(); + s.sort_by(f64::total_cmp); + let n = s.len(); + Some(if n % 2 == 1 { + s[n / 2] + } else { + (s[n / 2 - 1] + s[n / 2]) / 2.0 + }) +} + +/// One open issue, as much of it as a list needs. +/// +/// Every field here rides the pass the board already makes over every open +/// issue, so a cycle's list of them costs no request of its own. +#[derive(Clone, Default)] +struct Issue { + ident: String, + title: String, + url: String, + state: String, + /// Hours since it was created. + age: f64, + /// None when nobody has pointed it, which is not the same as zero. + points: Option<f64>, +} + +/// One project, as much of it as a team's screen needs. +/// +/// `progress` is Linear's own published figure, not one derived here: it +/// counts issues this widget never fetches, because the board asks only for +/// what is open and a finished project has none. +#[derive(Clone, Default)] +struct Proj { + id: String, + name: String, + /// The workspace's own name for the status - "In Progress", not + /// "started" - with `kind` left to pick the colour. + label: String, + kind: String, + progress: f64, + target: String, + lead: String, + /// Every team that owns it. A project can be shared, and the board's own + /// list is not filed under any one of them. + teams: Vec<String>, +} + +/// One project's own record, fetched when its screen opens. +/// +/// The error is kept in the same place the answer would be. A fetch that +/// fails silently leaves a screen saying "loading" for ever, which is this +/// widget's oldest lesson written a different way. +fn fetch_project(id: &str, tok: &str, quota: &Arc<Mutex<Quota>>) -> serde_json::Value { + let vars = serde_json::json!({ "id": id }); + match graphql(PROJECT_QUERY, tok, vars, quota) { + Ok(v) => v["project"].clone(), + Err(said) => serde_json::json!({ "_error": said }), + } +} + +/// How much of a project row each optional column gets. +/// +/// `base` is what never goes - the marker, the team key, the name and the +/// percentage. Returns what the status column costs, how wide the bar may +/// be (zero for none), and how many columns are left for the open count. +/// +/// The order things go in as the pane narrows: the bar first, because the +/// percentage beside it says the same thing in five columns; then the +/// status, which the colour and the ordering carry anyway; and last the +/// open count, which is the one number on the row the percentage does not +/// say. Nothing is ever cut through the middle. +/// +/// This is a function because the arithmetic was wrong twice inline: once +/// overflowing the pane and letting `seg` cut the percentage off, and once +/// reserving the bar's gap but not the open count's - which made a row lose +/// its open count as the pane got *wider*. +fn project_columns(w: usize, base: usize, label_w: usize, aside: usize) -> (usize, usize, usize) { + let budget = w.saturating_sub(1); + // Each column is shown only when it and everything above it in the + // order fits, and the ones above it are reserved whether or not they + // are shown. Anything less than that and widening the pane by one + // column can trade one fact for another - at seventy-seven columns the + // status arrived and the open count left, which is a pane that gets + // wider and says less. + let need_aside = if aside > 0 { 2 + aside } else { 0 }; + let need_label = 2 + label_w; + let room = if base + need_aside <= budget { aside } else { 0 }; + let label_cost = if base + need_aside + need_label <= budget { need_label } else { 0 }; + let bar = budget.saturating_sub(base + need_aside + need_label + 2); + let bar = if bar >= 6 { bar.min(30) } else { 0 }; + (label_cost, bar, room) +} + + +/// Break text to a width without breaking a word, and without dropping one. +fn wrap(t: &str, width: usize) -> Vec<String> { + if width == 0 { + return Vec::new(); + } + let mut out: Vec<String> = Vec::new(); + let mut line = String::new(); + for word in t.split_whitespace() { + let n = word.chars().count(); + if line.is_empty() { + line = word.to_string(); + } else if line.chars().count() + 1 + n <= width { + line.push(' '); + line.push_str(word); + } else { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } + // A word longer than the line has to break somewhere; it breaks at + // the width rather than being thrown away. + while line.chars().count() > width { + let head: String = line.chars().take(width).collect(); + line = line.chars().skip(width).collect(); + out.push(head); + } + } + if !line.is_empty() { + out.push(line); + } + out +} + +/// A project's aside, as one string. Measured and drawn through the same +/// call so the separator cannot be counted one way and printed another. +fn joined(parts: &[String]) -> String { + parts.join(" · ") +} + +/// What a section heading says about the keys, given where the focus is. +/// +/// The focused one carries the arrows. Exactly one other carries `[tab]` - +/// the one tab would actually focus next - because tab cycles, and a +/// `[tab]` on every heading would promise three sections that one press +/// reaches, when one press reaches one of them. netwatch learned this the +/// expensive way with per-heading letters bound to keys that had gone; the +/// note in its `section_head` says tab was the only one that was ever true. +fn heading_keys(me: usize, focus: Option<usize>, lens: &[usize]) -> &'static str { + if focus == Some(me) { + " ↑↓" + } else if tc::next_section(focus, lens) == Some(me) { + " [tab] to focus" + } else { + "" + } +} + +/// A count with its noun, singular when it is one. +fn plural(n: usize, noun: &str) -> String { + if n == 1 { + format!("{} {}", n, noun) + } else { + format!("{} {}s", n, noun) + } +} + +/// Where an issue's state sorts: what is moving first, what nobody has +/// looked at last. +fn state_rank(state: &str) -> usize { + match state { + "started" => 0, + "unstarted" => 1, + "backlog" => 2, + "triage" => 3, + _ => 4, + } +} + +/// Where a project's status sorts, running work first and finished last. +/// +/// An unknown status sorts with the live ones rather than the dead ones: a +/// workspace can name its own statuses, and burying one this build has not +/// heard of would hide real work. +fn rank(kind: &str) -> usize { + match kind { + "started" => 0, + "planned" => 2, + "paused" => 3, + "backlog" => 4, + "completed" => 5, + "canceled" => 6, + _ => 1, + } +} + +/// An issue worth going and looking at: how long, and which one. +type Extreme = Option<(f64, String)>; + +#[derive(Default)] +struct State { + teams: Vec<(String, String)>, + states: HashMap<String, usize>, + by_team: HashMap<String, HashMap<String, usize>>, + /// Team key to that team's projects, ordered as the screen shows them. + projects: HashMap<String, Vec<Proj>>, + /// Every project once, in the same order, for the board's own section. + all_projects: Vec<Proj>, + /// Team key to project id to how many of that team's open issues sit in + /// it. The empty id is the bucket for issues in no project at all, which + /// is why the per-project figures do not sum to the team's open count. + proj_open: HashMap<String, HashMap<String, usize>>, + /// Project id to its open issues by state, across every team that shares + /// it - a project's own screen is about the project, not about whichever + /// team's screen it was opened from. + proj_state: HashMap<String, HashMap<String, usize>>, + /// Project id to the oldest thing still open in it. + proj_oldest: HashMap<String, (f64, String)>, + /// Cycle id to its open issues by state, and to the issues themselves in + /// the order a screen shows them. + cycle_state: HashMap<String, HashMap<String, usize>>, + cycle_issues: HashMap<String, Vec<Issue>>, + cycles: Vec<serde_json::Value>, + created: HashMap<String, usize>, + completed: HashMap<String, usize>, + lead: Vec<f64>, + cycle_time: Vec<f64>, + quickest: Extreme, + slowest: Extreme, + oldest_open: Extreme, + oldest_wip: Extreme, + /// Which window the counters describe, which is not always the window + /// the keys have asked for. + window: i64, + truncated: bool, + /// The cycle walk stopped at the page cap, so `cycles` is a floor. Kept + /// apart from `truncated`, which is drawn against the open-issue count + /// and would be pointing at the wrong number. + cycles_capped: bool, + err: String, + fetched: f64, +} + +/// Whether a team key counts toward the board. +/// +/// Named teams win outright; otherwise everything not excluded is in. This +/// lived inside `one_pass` as a closure, where the only way to reach it was +/// a live token and a network round trip - so its test asserted a copy +/// written in the test body, and would have passed with this wrong. +fn team_wanted(keep: &[String], exclude: &[String], key: &str) -> bool { + if !keep.is_empty() { + keep.iter().any(|k| k == key) + } else { + !exclude.iter().any(|k| k == key) + } +} + +#[allow(clippy::too_many_arguments)] +fn one_pass( + tok: &str, + source: &str, + days: i64, + keep: &[String], + exclude: &[String], + state: &Arc<Mutex<State>>, + quota: &Arc<Mutex<Quota>>, +) -> Result<(), String> { + let wanted = |key: &str| team_wanted(keep, exclude, key); + let since = (Utc::now() - chrono::Duration::days(days - 1)) + .format("%Y-%m-%dT00:00:00.000Z") + .to_string(); + + // Every counter below is filtered through these keys, so a team left + // off the end of the first page understates all of them at once. The + // cap joins the board's own truncation marker for that reason. + let (team_nodes, cap_teams) = + pages(tok, TEAMS_QUERY, &["teams"], &serde_json::json!({}), quota)?; + let teams: Vec<(String, String)> = team_nodes + .iter() + .map(|t| (text(t, "key"), text(t, "name"))) + .filter(|(key, _)| wanted(key)) + .collect(); + let keys: Vec<String> = teams.iter().map(|(k, _)| k.clone()).collect(); + if let Ok(mut guard) = state.lock() { + guard.teams = teams.clone(); + } + + // What is outstanding right now, at any age. + let (rows, capped) = pages(tok, &open_query(), &["issues"], &serde_json::json!({}), quota)?; + let mut states: HashMap<String, usize> = HashMap::new(); + let mut by_team: HashMap<String, HashMap<String, usize>> = HashMap::new(); + let mut proj_open: HashMap<String, HashMap<String, usize>> = HashMap::new(); + let mut proj_state: HashMap<String, HashMap<String, usize>> = HashMap::new(); + let mut proj_oldest: HashMap<String, (f64, String)> = HashMap::new(); + let mut cycle_state: HashMap<String, HashMap<String, usize>> = HashMap::new(); + let mut cycle_issues: HashMap<String, Vec<Issue>> = HashMap::new(); + let at = Utc::now().naive_utc(); + let (mut oldest_open, mut oldest_wip): (Extreme, Extreme) = (None, None); + for it in &rows { + let key = text(&it["team"], "key"); + if !keys.contains(&key) { + continue; + } + let st = text(&it["state"], "type"); + if !STATE_ORDER.contains(&st.as_str()) { + continue; + } + *states.entry(st.clone()).or_insert(0) += 1; + // The empty string when the issue is in no project, which is a + // real and common answer here, not a missing one. + let in_project = text(&it["project"], "id"); + *proj_open + .entry(key.clone()) + .or_default() + .entry(in_project.clone()) + .or_insert(0) += 1; + // A project's own screen wants these broken out, and every one of + // them is already in this row: no second request buys them. + if !in_project.is_empty() { + *proj_state + .entry(in_project.clone()) + .or_default() + .entry(st.clone()) + .or_insert(0) += 1; + if let Some(age) = hours_since(parse(&text(it, "createdAt")), Some(at)) { + let seat = proj_oldest.entry(in_project).or_insert((0.0, String::new())); + if age > seat.0 { + *seat = (age, text(it, "identifier")); + } + } + } + // Which cycle it is in, if any, and the issue itself - both ride + // this pass, so a cycle's screen costs no request of its own. + let in_cycle = text(&it["cycle"], "id"); + if !in_cycle.is_empty() { + *cycle_state + .entry(in_cycle.clone()) + .or_default() + .entry(st.clone()) + .or_insert(0) += 1; + cycle_issues.entry(in_cycle).or_default().push(Issue { + ident: text(it, "identifier"), + title: text(it, "title"), + url: text(it, "url"), + state: st.clone(), + age: hours_since(parse(&text(it, "createdAt")), Some(at)).unwrap_or(0.0), + points: it["estimate"].as_f64(), + }); + } + let slot = by_team.entry(key).or_default(); + *slot.entry(st.clone()).or_insert(0) += 1; + *slot.entry("open".into()).or_insert(0) += 1; + let ident = text(it, "identifier"); + if let Some(age) = hours_since(parse(&text(it, "createdAt")), Some(at)) { + if oldest_open.as_ref().is_none_or(|(had, _)| age > *had) { + oldest_open = Some((age, ident.clone())); + } + } + if st == "started" { + if let Some(age) = hours_since(parse(&text(it, "startedAt")), Some(at)) { + if oldest_wip.as_ref().is_none_or(|(had, _)| age > *had) { + oldest_wip = Some((age, ident)); + } + } + } + } + + // The running cycles, each already carrying its own burndown. + let (cycle_nodes, cycles_capped) = + pages(tok, CYCLES_QUERY, &["cycles"], &serde_json::json!({}), quota)?; + let cycles: Vec<serde_json::Value> = cycle_nodes + .into_iter() + .filter(|c| keys.contains(&text(&c["team"], "key"))) + .collect(); + + // Every project, filed under each team that owns it. A project can be + // shared, so one node lands in more than one team's list. + let (proj_rows, cap4) = pages( + tok, + PROJECTS_QUERY, + &["projects"], + &serde_json::json!({}), + quota, + )?; + let mut projects: HashMap<String, Vec<Proj>> = HashMap::new(); + let mut all_projects: Vec<Proj> = Vec::new(); + for pr in &proj_rows { + let owners: Vec<String> = pr["teams"]["nodes"] + .as_array() + .into_iter() + .flatten() + .map(|t| text(t, "key")) + .filter(|k| keys.contains(k)) + .collect(); + if owners.is_empty() { + continue; + } + let made = Proj { + id: text(pr, "id"), + name: text(pr, "name"), + label: text(&pr["status"], "name"), + kind: text(&pr["status"], "type"), + progress: pr["progress"].as_f64().unwrap_or(0.0), + target: text(pr, "targetDate"), + lead: text(&pr["lead"], "name"), + teams: owners.clone(), + }; + for key in owners { + projects.entry(key).or_default().push(made.clone()); + } + all_projects.push(made); + } + let order = |a: &Proj, b: &Proj| { + rank(&a.kind) + .cmp(&rank(&b.kind)) + .then(b.progress.total_cmp(&a.progress)) + .then(a.name.cmp(&b.name)) + }; + for list in projects.values_mut() { + list.sort_by(&order); + } + all_projects.sort_by(&order); + + // Arrivals and departures over the window. + let vars = serde_json::json!({ "since": since }); + let (made, cap2) = pages(tok, &created_query(), &["issues"], &vars, quota)?; + let (done, cap3) = pages(tok, &done_query(), &["issues"], &vars, quota)?; + let mut created: HashMap<String, usize> = HashMap::new(); + let mut completed: HashMap<String, usize> = HashMap::new(); + let (mut lead, mut ctime): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new()); + let (mut quickest, mut slowest): (Extreme, Extreme) = (None, None); + for it in &made { + if keys.contains(&text(&it["team"], "key")) { + *created.entry(day(&text(it, "createdAt"))).or_insert(0) += 1; + } + } + for it in &done { + if !keys.contains(&text(&it["team"], "key")) { + continue; + } + *completed.entry(day(&text(it, "completedAt"))).or_insert(0) += 1; + let fin = parse(&text(it, "completedAt")); + let ident = text(it, "identifier"); + if let Some(hrs) = hours_since(parse(&text(it, "createdAt")), fin) { + lead.push(hrs); + if quickest.as_ref().is_none_or(|(had, _)| hrs < *had) { + quickest = Some((hrs, ident.clone())); + } + if slowest.as_ref().is_none_or(|(had, _)| hrs > *had) { + slowest = Some((hrs, ident)); + } + } + if let Some(hrs) = hours_since(parse(&text(it, "startedAt")), fin) { + ctime.push(hrs); + } + } + // What is being worked first, then what is waiting, and inside each the + // oldest first. Ties break on the identifier so a poll cannot shuffle + // two equal rows under the cursor. + for list in cycle_issues.values_mut() { + list.sort_by(|a, b| { + state_rank(&a.state) + .cmp(&state_rank(&b.state)) + .then(b.age.total_cmp(&a.age)) + .then(a.ident.cmp(&b.ident)) + }); + } + + for (key, slot) in by_team.iter_mut() { + let n = done + .iter() + .filter(|it| text(&it["team"], "key") == *key) + .count(); + slot.insert("done".into(), n); + } + + if let Ok(mut guard) = state.lock() { + guard.states = states; + guard.by_team = by_team; + guard.projects = projects; + guard.all_projects = all_projects; + guard.proj_open = proj_open; + guard.proj_state = proj_state; + guard.proj_oldest = proj_oldest; + guard.cycle_state = cycle_state; + guard.cycle_issues = cycle_issues; + guard.cycles = cycles; + guard.created = created; + guard.completed = completed; + guard.lead = lead; + guard.cycle_time = ctime; + guard.quickest = quickest; + guard.slowest = slowest; + guard.oldest_open = oldest_open; + guard.oldest_wip = oldest_wip; + guard.window = days; + guard.truncated = capped || cap2 || cap3 || cap4 || cap_teams; + guard.cycles_capped = cycles_capped; + guard.fetched = now(); + guard.err = if source == "config" { + tc::config_token_warning().unwrap_or_default() + } else { + String::new() + }; + } + Ok(()) +} + +struct Palette { + ok: String, + warn: String, + bad: String, + bad_lit: String, + dim: String, + /// A colour to draw over the selected-row tint. + /// + /// `dim` is 3.81 against `bg(38, 56, 76)`, under the 4.5 CLAUDE.md asks for + /// against the tint as well as the background. This is the same grey lifted + /// until it clears - 4.94 - and it is used *only* where a tint is on, so an + /// untinted row is exactly the colour it always was. Not quite the same as + /// "unselected": herdr-panes tints a blocked or done row whether or not it + /// is selected, and those get the lighter colours too. + /// + /// The substitution happens inside the closure that composes the tint, not + /// at each call site. Seventeen sites were counted when this was found and + /// there were twenty-three by the time it was fixed; more than half of them + /// reach `dim` through a condition that has nothing to do with selection - + /// `if count > 0 { loud } else { dim }` - and a zero count is the normal + /// state, so those are the common case rather than the rare one. Anyone + /// fixing this a call site at a time would fix the obvious half. + dim_lit: String, + grid: String, + txt: String, + lbl: String, + accent: String, + new: String, +} + +const GHOST: (u8, u8, u8) = (96, 106, 124); +const NEW_RGB: (u8, u8, u8) = (180, 160, 255); +const OK_RGB: (u8, u8, u8) = (90, 240, 160); + +fn palette() -> Palette { + Palette { + ok: tc::rgb(90, 240, 160), + warn: tc::rgb(255, 200, 90), + bad: tc::rgb(255, 100, 110), + bad_lit: tc::rgb(255, 128, 136), + dim: tc::rgb(127, 147, 172), + dim_lit: tc::rgb(140, 170, 195), + grid: tc::rgb(60, 78, 98), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + accent: tc::rgb(150, 210, 255), + new: tc::rgb(180, 160, 255), + } +} + +fn state_colour<'a>(state: &str, p: &'a Palette) -> &'a str { + match state { + "triage" => &p.bad, + "backlog" => &p.dim, + "unstarted" => &p.accent, + _ => &p.warn, + } +} + +/// How many cycles are running - or, when the walk stopped at the page cap, +/// how many are known to be running. +/// +/// A count that stopped counting is a floor, and saying "at least" is the +/// difference between a board that is quiet and a board that gave up. +fn running_label(n: usize, capped: bool) -> String { + if capped { + format!("at least {} running", n) + } else { + format!("{} running", n) + } +} + +fn state_label(state: &str) -> &'static str { + match state { + "triage" => "triage", + "backlog" => "backlog", + "unstarted" => "todo", + _ => "in progress", + } +} + +/// One cycle in full: how much work it holds, how much is done, and the +/// shape of both since it opened. +/// +/// The board ranks cycles by churn and shows a bar. The two histories behind +/// that bar are the interesting part and there is no room for them in a row: +/// scope rising while completed stays flat is a cycle taking on work, and +/// the two converging is one closing. Nothing here is a new request - the +/// arrays arrive with the cycle. +#[allow(clippy::too_many_arguments)] +fn cycle_detail( + c: &serde_json::Value, + states: &HashMap<String, usize>, + issues: &[Issue], + pick: usize, + w: usize, + h: usize, + p: &Palette, +) -> (Vec<String>, Option<usize>) { + let team = text(&c["team"], "name"); + // Linear cycles are often unnamed - the board falls back to their + // number and so does this, or the title reads " · TEAM" with a leading + // separator hanging off nothing. + let named = match text(c, "name") { + n if n.is_empty() => format!("Cycle {}", tidy(c["number"].as_f64().unwrap_or(0.0))), + n => n, + }; + let title = if team.is_empty() { named } else { format!("{} · {}", named, team) }; + let mut rows = vec![tc::title(&title, w, &p.accent)]; + let label_w = 18usize; + let mut field = |name: &str, value: String, aside: String, colour: &str| { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", tc::pad(name, label_w))), + (colour, format!("{:>7}", value)), + (p.dim.as_str(), format!(" {}", aside)), + ], + w - 1, + )); + }; + + let series = |key: &str| -> Vec<f64> { + c[key] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_f64()) + .collect() + }; + let scope = series("scopeHistory"); + let done = series("completedScopeHistory"); + let issue_count = series("issueCountHistory"); + let issues_closed = series("completedIssueCountHistory"); + + let at = |v: &[f64]| v.last().copied().unwrap_or(0.0); + let pct = if at(&scope) > 0.0 { + 100.0 * at(&done) / at(&scope) + } else { + 0.0 + }; + field( + "progress", + format!("{:.0}%", pct), + tc::meter(pct / 100.0, w.saturating_sub(label_w + 22).clamp(6, 24)), + if pct >= 80.0 { p.ok.as_str() } else { p.txt.as_str() }, + ); + field( + "scope", + format!("{:.0}", at(&scope)), + format!("{:.0} done, {:.0} left", at(&done), (at(&scope) - at(&done)).max(0.0)), + p.txt.as_str(), + ); + if !issue_count.is_empty() { + field( + "issues", + format!("{:.0}", at(&issue_count)), + format!("{:.0} closed", at(&issues_closed)), + p.dim.as_str(), + ); + } + // Scope that appeared after the cycle opened. The number people mean + // when they say a cycle "grew". + let added = at(&scope) - scope.first().copied().unwrap_or(0.0); + if added.abs() > 0.001 { + field( + "scope added", + format!("{:+.0}", added), + "since it opened".into(), + if added > 0.0 { p.warn.as_str() } else { p.ok.as_str() }, + ); + } + let (moved, left) = churn(c); + field( + "ends in", + if left >= 999 { "—".into() } else { format!("{}d", left) }, + text(c, "endsAt").chars().take(10).collect::<String>(), + p.dim.as_str(), + ); + // +0.0 formats as "-0" when the sum is a hair below zero, which reads + // as a negative churn and is not one. + field( + "churn", + format!("{:.0}", if moved.abs() < 0.05 { 0.0 } else { moved }), + "points moved lately".into(), + p.dim.as_str(), + ); + // What is left against what the cycle has been closing per day. The + // question a burn-up is usually read to answer, stated. + let days = done.len().max(1) as f64; + let rate = at(&done) / days; + if rate > 0.0 { + let remaining = (at(&scope) - at(&done)).max(0.0); + field( + "at this rate", + format!("{:.0}d", remaining / rate), + if left < 999 && remaining / rate > left as f64 { + "longer than the cycle has".into() + } else { + "to clear what is left".into() + }, + if left < 999 && remaining / rate > left as f64 { + p.warn.as_str() + } else { + p.ok.as_str() + }, + ); + } + + // Scope above the line, completed below it, on one scale. + if h.saturating_sub(rows.len()) >= 10 && scope.len() > 1 { + let hi = scope.iter().chain(done.iter()).cloned().fold(1.0f64, f64::max); + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── BURN-UP ── ".into()), + (p.dim.as_str(), format!("{} days · ", scope.len())), + (p.txt.as_str(), "▲ scope".into()), + (p.dim.as_str(), " · ".into()), + (p.ok.as_str(), "▼ completed".into()), + ], + w - 1, + )); + let cols = w.saturating_sub(3).max(10); + let fit = |v: &[f64]| -> Vec<f64> { + if v.len() >= cols { + v[v.len() - cols..].to_vec() + } else { + v.to_vec() + } + }; + for line in tc::vbars( + &fit(&scope).iter().map(|v| (*v, p.txt.clone())).collect::<Vec<_>>(), + 3, + hi, + ) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + rows.push(tc::seg( + &[(tc::RST, " ".into()), (p.grid.as_str(), "─".repeat(fit(&scope).len()))], + w - 1, + )); + for line in tc::vbars_down( + &fit(&done).iter().map(|v| (*v, p.ok.clone())).collect::<Vec<_>>(), + 3, + hi, + ) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + } + // What is open in it right now, by state. The burn-up above is where + // the cycle has been; this is where it is. + let open: usize = states.values().sum(); + if open > 0 { + let legend: Vec<(&str, usize, &str)> = STATE_ORDER + .iter() + .map(|st| (state_label(st), states.get(*st).copied().unwrap_or(0), state_colour(st, p))) + .filter(|x| x.1 > 0) + .collect(); + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPEN BY STATE ── ".into()), + (p.dim.as_str(), plural(open, "issue")), + ], + w - 1, + )); + let parts: Vec<(f64, String)> = legend + .iter() + .map(|(_, n, c)| (*n as f64 / open as f64, c.to_string())) + .collect(); + let bar = tc::stacked_bar(&parts, w.saturating_sub(3).max(10)); + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, txt) in &bar { + line.push((colour.as_str(), txt.clone())); + } + rows.push(tc::seg(&line, w - 1)); + let mut legend_row: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, count, colour) in &legend { + legend_row.push((colour, "▇ ".into())); + legend_row.push((p.txt.as_str(), (*label).into())); + legend_row.push(( + p.dim.as_str(), + format!(" {} ({:.0}%) ", count, 100.0 * *count as f64 / open as f64), + )); + } + rows.push(tc::seg(&legend_row, w - 1)); + } + + // The issues themselves. Only the open ones are here, because the open + // ones are all this widget ever fetches - so the heading says how many + // are closed rather than letting a count of one read as a cycle of one. + let closed = (at(&issues_closed) as usize).min(at(&issue_count) as usize); + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPEN IN THIS CYCLE ── ".into()), + ( + p.dim.as_str(), + if closed > 0 { + format!("{} · {} closed, not listed", issues.len(), closed) + } else { + format!("{}", issues.len()) + }, + ), + ], + w - 1, + )); + if issues.is_empty() { + rows.push(tc::seg( + &[( + p.dim.as_str(), + if closed > 0 { + " nothing open in it - every issue is closed".into() + } else { + " nothing open in it".to_string() + }, + )], + w - 1, + )); + return (rows, None); + } + + let pick = pick.min(issues.len() - 1); + let mut cursor = None; + let widest = |xs: &mut dyn Iterator<Item = usize>| xs.max().unwrap_or(0); + let ident_w = widest(&mut issues.iter().map(|i| i.ident.chars().count())).max(6); + let state_w = widest(&mut issues.iter().map(|i| state_label(&i.state).chars().count())).max(4); + // Identifier, state, age and points are fixed; the title takes the rest, + // and is the one thing here long enough to need it. + let head = 2 + ident_w + 2 + state_w + 2 + 6 + 2 + 5 + 2; + let title_w = (w - 1).saturating_sub(head).max(10); + for (i, it) in issues.iter().enumerate() { + let here = i == pick; + if here { + cursor = Some(rows.len()); + } + // A title long enough to overrun the column carries on underneath + // it rather than being cut. An issue titled "Verify fares · Sun + // Ferry Services · Peng Chau" and one cut to the same forty + // characters are two different issues on screen. + let lines = wrap(&it.title, title_w); + let (first, rest) = lines.split_first().map(|(a, b)| (a.clone(), b)).unwrap_or_default(); + rows.push(tc::seg( + &[ + ( + if here { p.accent.as_str() } else { p.dim.as_str() }, + format!("{} {}", if here { "▸" } else { " " }, tc::pad(&it.ident, ident_w)), + ), + ( + state_colour(&it.state, p), + format!(" {}", tc::pad(state_label(&it.state), state_w)), + ), + ( + if here { p.accent.as_str() } else { p.txt.as_str() }, + format!(" {}", tc::pad(&first, title_w)), + ), + (p.dim.as_str(), format!(" {:>6}", dur(Some(it.age)))), + // Nothing rather than "0 pts" when nobody has pointed it. + ( + p.dim.as_str(), + match it.points { + Some(n) if n > 0.0 => format!(" {:>4}", format!("{}p", tidy(n))), + _ => " ".to_string(), + }, + ), + ], + w - 1, + )); + for line in rest { + rows.push(tc::seg( + &[( + if here { p.accent.as_str() } else { p.txt.as_str() }, + format!("{}{}", " ".repeat(2 + ident_w + 2 + state_w + 2), line), + )], + w - 1, + )); + } + } + (rows, cursor) +} + +/// One team in full: what it is holding, in the states it is holding it. +#[allow(clippy::too_many_arguments)] +fn team_detail( + key: &str, + name: &str, + counts: &HashMap<String, usize>, + projects: &[Proj], + opens: &HashMap<String, usize>, + pick: usize, + window: i64, + w: usize, + h: usize, + p: &Palette, +) -> (Vec<String>, Option<usize>) { + let mut rows = vec![tc::title(&format!("{} · {}", key, name), w, &p.accent)]; + let label_w = 18usize; + let get = |k: &str| counts.get(k).copied().unwrap_or(0); + let open = get("open"); + let mut field = |name: &str, value: String, aside: String, colour: &str| { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", tc::pad(name, label_w))), + (colour, format!("{:>7}", value)), + (p.dim.as_str(), format!(" {}", aside)), + ], + w - 1, + )); + }; + field("open", open.to_string(), "issues not closed".into(), p.txt.as_str()); + field( + "done", + get("done").to_string(), + format!("in the last {}d", window), + p.ok.as_str(), + ); + // Triage is the one worth calling out: it is work nobody has looked at, + // and a team can hold hundreds of it while looking busy everywhere else. + let triage = get("triage"); + if triage > 0 { + field( + "in triage", + triage.to_string(), + if open > 0 { + format!("{:.0}% of open, unlooked at", 100.0 * triage as f64 / open as f64) + } else { + String::new() + }, + p.bad.as_str(), + ); + } + field("in progress", get("started").to_string(), String::new(), p.warn.as_str()); + + if open > 0 && h.saturating_sub(rows.len()) >= 4 { + let legend: Vec<(&str, usize, &str)> = [ + (state_label("triage"), triage, p.bad.as_str()), + (state_label("backlog"), get("backlog"), p.dim.as_str()), + (state_label("unstarted"), get("unstarted"), p.txt.as_str()), + (state_label("started"), get("started"), p.warn.as_str()), + ] + .into_iter() + .filter(|x| x.1 > 0) + .collect(); + if !legend.is_empty() { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPEN BY STATE ── ".into()), + (p.dim.as_str(), plural(open, "issue")), + ], + w - 1, + )); + let parts: Vec<(f64, String)> = legend + .iter() + .map(|(_, n, c)| (*n as f64 / open as f64, c.to_string())) + .collect(); + let bar = tc::stacked_bar(&parts, w.saturating_sub(3).max(10)); + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, txt) in &bar { + line.push((colour.as_str(), txt.clone())); + } + rows.push(tc::seg(&line, w - 1)); + let mut legend_row: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, count, colour) in &legend { + legend_row.push((colour, "▇ ".into())); + legend_row.push((p.txt.as_str(), (*label).into())); + legend_row.push(( + p.dim.as_str(), + format!(" {} ({:.0}%) ", count, 100.0 * *count as f64 / open as f64), + )); + } + rows.push(tc::seg(&legend_row, w - 1)); + } + } + + rows.push(String::new()); + // The issues in no project. Said out loud because the per-project counts + // below will not add up to the team's open count without it, and a + // column of numbers that does not reconcile reads as a bug. + let loose = opens.get("").copied().unwrap_or(0); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── PROJECTS ── ".into()), + ( + p.dim.as_str(), + if projects.is_empty() { + "none".to_string() + } else if loose > 0 { + format!("{} · {} open in no project", projects.len(), loose) + } else { + format!("{}", projects.len()) + }, + ), + ], + w - 1, + )); + if projects.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " this team owns no projects".into())], + w - 1, + )); + return (rows, None); + } + + // Every aside first, because the columns are sized to what is in them: + // names and statuses take the width they need and the meter takes what + // is left, so a wider pane draws a longer bar rather than a margin. No + // column is capped, because capping one truncates a project's name and + // a half-written name is a name for something else. + // + // Only what a project actually has goes in its aside: one with no open + // issues left says nothing rather than "0 open", and one with no target + // date says nothing rather than an em dash. + let asides: Vec<Vec<String>> = projects + .iter() + .map(|q| { + let mut parts = Vec::new(); + let open = opens.get(&q.id).copied().unwrap_or(0); + if open > 0 { + parts.push(format!("{} open", open)); + } + if !q.target.is_empty() { + parts.push(format!("due {}", q.target)); + } + if !q.lead.is_empty() { + parts.push(q.lead.clone()); + } + parts + }) + .collect(); + let widest = |xs: &mut dyn Iterator<Item = usize>| xs.max().unwrap_or(0); + let name_w = widest(&mut projects.iter().map(|q| q.name.chars().count())).max(8); + let label_w = widest(&mut projects.iter().map(|q| q.label.chars().count())).max(4); + let full = widest(&mut asides.iter().map(|a| joined(a).chars().count())); + // Everything on the row except the bar and the aside, the two that give. + // The marker sits in the left margin the same way the board's own two + // panes carry it, so the two-space indent is what it displaces. + let head = 2 + name_w + 2 + label_w + 2 + 5 + 2; + let bar_w = (w - 1).saturating_sub(head + full).clamp(6, 40); + let room = (w - 1).saturating_sub(head + bar_w); + let pick = pick.min(projects.len() - 1); + let mut cursor = None; + for (i, (q, parts)) in projects.iter().zip(&asides).enumerate() { + let colour = match q.kind.as_str() { + "started" => p.warn.as_str(), + "completed" => p.ok.as_str(), + "canceled" => p.dim.as_str(), + _ => p.txt.as_str(), + }; + // Drop whole facts off the end rather than let the pane cut one in + // half: "due 2024-07-26 · William L" names nobody. + let mut keep = parts.len(); + while keep > 0 && joined(&parts[..keep]).chars().count() > room { + keep -= 1; + } + let here = i == pick; + if here { + cursor = Some(rows.len()); + } + rows.push(tc::seg( + &[ + ( + if here { p.accent.as_str() } else { p.txt.as_str() }, + format!("{} {}", if here { "▸" } else { " " }, tc::pad(&q.name, name_w)), + ), + (colour, format!(" {}", tc::pad(&q.label, label_w))), + (colour, format!(" {}", tc::meter(q.progress, bar_w))), + (p.txt.as_str(), format!(" {:>3.0}%", 100.0 * q.progress)), + (p.dim.as_str(), format!(" {}", joined(&parts[..keep]))), + ], + w - 1, + )); + } + (rows, cursor) +} + +/// One project in full. +/// +/// Two sources meet here and neither could answer alone. `held` is the +/// project's own record, fetched when this screen opened: its burn-up, its +/// milestones, who is on it. `states` and `oldest` come from the board's +/// own pass over every open issue, which is why they cost nothing and stay +/// current while the screen is up. +/// +/// `q` carries what the list already knew, so the screen has a name, a +/// status and a progress figure on the frame it opens - not one request +/// later. +#[allow(clippy::too_many_arguments)] +fn project_detail( + q: &Proj, + team: &str, + held: Option<&serde_json::Value>, + states: &HashMap<String, usize>, + oldest: Option<&(f64, String)>, + w: usize, + p: &Palette, +) -> Vec<String> { + let title = if team.is_empty() { + q.name.clone() + } else { + format!("{} · {}", q.name, team) + }; + let mut rows = vec![tc::title(&title, w, &p.accent)]; + let label_w = 18usize; + let mut field = |name: &str, value: String, aside: String, colour: &str| { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", tc::pad(name, label_w))), + (colour, format!("{:>7}", value)), + (p.dim.as_str(), format!(" {}", aside)), + ], + w - 1, + )); + }; + + // Linear's own progress, the same figure the team's list shows. Derived + // here it would disagree with the line the reader just came from. + field( + "progress", + format!("{:.0}%", 100.0 * q.progress), + tc::meter(q.progress, w.saturating_sub(label_w + 22).clamp(6, 24)), + if q.progress >= 0.8 { p.ok.as_str() } else { p.txt.as_str() }, + ); + field( + "status", + String::new(), + q.label.clone(), + p.txt.as_str(), + ); + + let series = |v: &serde_json::Value, key: &str| -> Vec<f64> { + v[key].as_array().into_iter().flatten().filter_map(|x| x.as_f64()).collect() + }; + let at = |v: &[f64]| v.last().copied().unwrap_or(0.0); + if let Some(v) = held.filter(|v| v["_error"].is_null() && !v.is_null()) { + let scope = series(v, "scopeHistory"); + let done = series(v, "completedScopeHistory"); + let issues = series(v, "issueCountHistory"); + let issues_done = series(v, "completedIssueCountHistory"); + if at(&scope) > 0.0 { + // Named in points because the percentage above is Linear's + // own weighting and agrees with neither this nor the issue + // count. Three different measures unlabelled read as one + // measure got wrong three times. + field( + "scope in points", + format!("{:.0}", at(&scope)), + format!("{:.0} done, {:.0} left", at(&done), (at(&scope) - at(&done)).max(0.0)), + p.txt.as_str(), + ); + } + if at(&issues) > 0.0 { + field( + "issues", + format!("{:.0}", at(&issues)), + format!("{:.0} closed", at(&issues_done)), + p.dim.as_str(), + ); + } + // Scope that arrived after the project opened - the number people + // mean when they say a project "grew". + let added = at(&scope) - scope.first().copied().unwrap_or(0.0); + if added.abs() > 0.05 { + field( + "scope added", + format!("{:+.0}", added), + "since it started".into(), + if added > 0.0 { p.warn.as_str() } else { p.ok.as_str() }, + ); + } + } + + let record = held.unwrap_or(&serde_json::Value::Null); + let started = day(&text(record, "startedAt")); + if !started.is_empty() { + field("started", String::new(), started, p.dim.as_str()); + } + let finished = day(&text(record, "completedAt")); + if !finished.is_empty() { + field("completed", String::new(), finished.clone(), p.ok.as_str()); + } + // A finished project is not late. It was shown as "overdue by 760d" + // because the target date is still in the past and nothing was asking + // whether the work had since landed. + let done_with = !finished.is_empty() || q.kind == "completed" || q.kind == "canceled"; + if !q.target.is_empty() && done_with { + field("target was", String::new(), q.target.clone(), p.dim.as_str()); + } else if !q.target.is_empty() { + // Whether the date is still ahead, said in days rather than left to + // the reader to work out from today's. + let left = parse(&format!("{}T00:00:00", q.target)) + .and_then(|d| hours_since(Some(Utc::now().naive_utc()), Some(d))) + .map(|h| (h / 24.0).ceil() as i64); + // The label carries the direction so the value stays short enough + // for the column, the way the cycle screen's "ends in" does. + let (label, word, colour) = match left { + Some(d) if d < 0 => ("overdue by", format!("{}d", -d), p.bad.as_str()), + Some(d) => ("target in", format!("{}d", d), p.txt.as_str()), + None => ("target", String::new(), p.dim.as_str()), + }; + field(label, word, q.target.clone(), colour); + } + if !q.lead.is_empty() { + field("lead", String::new(), q.lead.clone(), p.txt.as_str()); + } + if let Some(v) = held.filter(|v| v["_error"].is_null() && !v.is_null()) { + let names: Vec<String> = v["members"]["nodes"] + .as_array() + .into_iter() + .flatten() + .map(|m| text(m, "name")) + .filter(|n| !n.is_empty()) + .collect(); + // A page of members, not necessarily every member. Paginating it + // would mean asking for the whole project record again - burn-up, + // milestones and all - per page, every time this screen opens, to + // settle a question that is almost always already settled. So the + // connection says whether there are more and the row carries it: + // "21+" is honest where "21" would be wrong. + let more = v["members"]["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false); + if !names.is_empty() { + field( + "members", + if more { format!("{}+", names.len()) } else { names.len().to_string() }, + if more { format!("{}, …", names.join(", ")) } else { names.join(", ") }, + p.dim.as_str(), + ); + } + // Same honesty as members: this connection is five nodes, and + // presenting that page as the list would hide the rest. + let inits: Vec<String> = v["initiatives"]["nodes"] + .as_array() + .into_iter() + .flatten() + .map(|i| text(i, "name")) + .filter(|n| !n.is_empty()) + .collect(); + let more_inits = v["initiatives"]["pageInfo"]["hasNextPage"] + .as_bool() + .unwrap_or(false); + if !inits.is_empty() { + field( + "initiative", + if more_inits { + format!("{}+", inits.len()) + } else { + String::new() + }, + if more_inits { + format!("{} · …", inits.join(" · ")) + } else { + inits.join(" · ") + }, + p.dim.as_str(), + ); + } + } + if let Some((age, ident)) = oldest { + field("oldest open", dur(Some(*age)), ident.clone(), p.warn.as_str()); + } + + // What is open in it, by state, across every team that shares it. + let open: usize = states.values().sum(); + if open > 0 { + let legend: Vec<(&str, usize, &str)> = STATE_ORDER + .iter() + .map(|st| (state_label(st), states.get(*st).copied().unwrap_or(0), state_colour(st, p))) + .filter(|x| x.1 > 0) + .collect(); + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPEN BY STATE ── ".into()), + (p.dim.as_str(), plural(open, "issue")), + ], + w - 1, + )); + let parts: Vec<(f64, String)> = legend + .iter() + .map(|(_, n, c)| (*n as f64 / open as f64, c.to_string())) + .collect(); + let bar = tc::stacked_bar(&parts, w.saturating_sub(3).max(10)); + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, txt) in &bar { + line.push((colour.as_str(), txt.clone())); + } + rows.push(tc::seg(&line, w - 1)); + let mut legend_row: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, count, colour) in &legend { + legend_row.push((colour, "▇ ".into())); + legend_row.push((p.txt.as_str(), (*label).into())); + legend_row.push(( + p.dim.as_str(), + format!(" {} ({:.0}%) ", count, 100.0 * *count as f64 / open as f64), + )); + } + rows.push(tc::seg(&legend_row, w - 1)); + } + + match held { + None => { + rows.push(String::new()); + rows.push(tc::seg(&[(p.dim.as_str(), " asking Linear for the rest...".into())], w - 1)); + return rows; + } + Some(v) if !v["_error"].is_null() => { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.bad.as_str(), " could not read the project: ".into()), + (p.dim.as_str(), text(v, "_error")), + ], + w - 1, + )); + return rows; + } + Some(v) if v.is_null() => { + rows.push(String::new()); + rows.push(tc::seg(&[(p.bad.as_str(), " Linear returned no such project".into())], w - 1)); + return rows; + } + _ => {} + } + let v = held.unwrap_or(&serde_json::Value::Null); + + // Milestones, in the order they fall due. Linear returns them in no + // order at all, and a list of dates out of order reads as noise. + let more_stones = v["projectMilestones"]["pageInfo"]["hasNextPage"] + .as_bool() + .unwrap_or(false); + let mut stones: Vec<(String, String, f64)> = v["projectMilestones"]["nodes"] + .as_array() + .into_iter() + .flatten() + .map(|m| { + ( + text(m, "name"), + text(m, "targetDate"), + // Milestones report progress out of a hundred where the + // project reports it out of one. Fed straight to a meter + // every one of them would read full. + m["progress"].as_f64().unwrap_or(0.0) / 100.0, + ) + }) + .collect(); + // Undated last: they have no place in a sequence of dates. + stones.sort_by(|a, b| { + a.1.is_empty().cmp(&b.1.is_empty()).then(a.1.cmp(&b.1)).then(a.0.cmp(&b.0)) + }); + if !stones.is_empty() { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── MILESTONES ── ".into()), + // Same rule as the members row: a page of milestones is not + // necessarily every milestone, and "25" would say it was. + ( + p.dim.as_str(), + if more_stones { + format!("{}+", stones.len()) + } else { + stones.len().to_string() + }, + ), + ], + w - 1, + )); + let name_w = stones.iter().map(|(n, _, _)| n.chars().count()).max().unwrap_or(8).max(8); + let bar_w = (w - 1).saturating_sub(2 + name_w + 2 + 5 + 2 + 10).clamp(6, 30); + for (name, date, frac) in &stones { + let colour = if *frac >= 1.0 { p.ok.as_str() } else { p.txt.as_str() }; + rows.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {}", tc::pad(name, name_w))), + (colour, format!(" {}", tc::meter(*frac, bar_w))), + (p.txt.as_str(), format!(" {:>3.0}%", 100.0 * frac)), + (p.dim.as_str(), format!(" {}", date)), + ], + w - 1, + )); + } + } + + let about = text(v, "description"); + if !about.trim().is_empty() { + rows.push(String::new()); + rows.push(tc::seg(&[(p.lbl.as_str(), " ── WHAT IT IS FOR ── ".into())], w - 1)); + // Wrapped whole, never cut: this screen scrolls, so there is no + // width to buy by dropping the end of a sentence. + for line in wrap(&about, w.saturating_sub(4)) { + rows.push(tc::seg(&[(p.txt.as_str(), format!(" {}", line))], w - 1)); + } + } + rows +} + +/// How much a cycle has moved lately, for ranking. +/// +/// The burndown arrays already say where the action is: day-over-day +/// movement in completed scope and in scope itself, summed over the tail. +/// A cycle nothing has touched in a week is not interesting however close +/// its deadline, and an empty one scores zero and sinks without a special +/// case. Deadline breaks ties. +fn churn(c: &serde_json::Value) -> (f64, i64) { + let mut moved = 0.0; + for series in ["completedScopeHistory", "scopeHistory"] { + let all: Vec<f64> = c[series] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_f64()) + .collect(); + let tail = &all[all.len().saturating_sub(CHURN_DAYS)..]; + moved += tail.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>(); + } + let left = match parse(&text(c, "endsAt")) { + Some(ends) => (ends - Utc::now().naive_utc()).num_days(), + None => 999, + }; + (-moved, left) +} + +fn last_of(c: &serde_json::Value, key: &str) -> f64 { + c[key] + .as_array() + .and_then(|a| a.last()) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) +} + +fn first_of(c: &serde_json::Value, key: &str) -> f64 { + c[key] + .as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) +} + +/// A number as the Python's %g writes it: no trailing zeros on a whole one. +fn tidy(v: f64) -> String { + if v.fract().abs() < 1e-9 { + format!("{}", v as i64) + } else { + format!("{}", v) + } +} + +fn main() { + tc::maybe_help(include_str!("linear_help.txt")); + let cfg = tc::load_config("linear"); + let mut refresh = tc::cfg_f64(&cfg, "refresh", 120.0); + let exclude: Vec<String> = tc::cfg_strings(&cfg, "exclude_teams", &[]); + let start_window = (tc::cfg_f64(&cfg, "window_days", 14.0) as i64).max(1); + + let args: Vec<String> = std::env::args().skip(1).collect(); + let mut keep: Vec<String> = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-n" | "--refresh" if i + 1 < args.len() => { + refresh = args[i + 1].parse().unwrap_or(120.0); + i += 2; + } + other if !other.starts_with('-') => { + keep.push(other.to_uppercase()); + i += 1; + } + _ => i += 1, + } + } + refresh = tc::poll_secs(refresh, 120.0); + + let absent = tc::missing(&["curl"]); + if !absent.is_empty() { + tc::cannot_start( + "linear ops", + &absent, + &[ + "Everything here comes from Linear's GraphQL API, and curl is", + "how this reaches it - the same way the other widgets reach", + "ss, ping and tailscale.", + "", + "The key is passed to curl on its standard input rather than", + "in its arguments, because /proc/<pid>/cmdline is readable by", + "every user on the machine.", + ], + "apt install curl", + ); + return; + } + + let p = palette(); + let state = Arc::new(Mutex::new(State { + window: start_window, + ..Default::default() + })); + let quota = Arc::new(Mutex::new(Quota::default())); + let days = Arc::new(Mutex::new(start_window)); + let wake = Arc::new((Mutex::new(false), Condvar::new())); + + let (tok, source) = token(&cfg); + // The poller takes ownership of the key; the project screen fetches on + // its own thread and needs one too. + let ui_token = tok.clone(); + let env_name = { + let name = tc::cfg_str(&cfg, "token_env", "LINEAR_API_KEY"); + if name.is_empty() { "LINEAR_API_KEY".to_string() } else { name } + }; + let poller = Arc::clone(&state); + let poller_wake = Arc::clone(&wake); + let poller_days = Arc::clone(&days); + let poller_quota = Arc::clone("a); + std::thread::spawn(move || loop { + if tok.is_empty() { + if let Ok(mut guard) = poller.lock() { + guard.err = format!( + "no key: set linear.token in config.json or ${}", + env_name + ); + } + } else { + let want = poller_days.lock().map(|g| *g).unwrap_or(14); + if let Err(said) = one_pass( + &tok, + source, + want, + &keep, + &exclude, + &poller, + &poller_quota, + ) { + if let Ok(mut guard) = poller.lock() { + guard.err = said; + } + } + } + let (lock, cond) = &*poller_wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(refresh)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + *asked = false; + }); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + // Two sections take the arrows, so they need to know which one they + // are in. Tab moves the focus; the focused heading says so. + // + // None is a real state, not a missing value: the board opens with no + // cursor anywhere, because it is a thing to read before it is a thing + // to work, and a cursor sitting somewhere you did not put it is a + // question rather than an answer. It also used to open on the cycles + // pane while the footer said the arrows scrolled, which was wrong + // twice over. + // In the order they are drawn, which is the order the arrows walk them. + let (cycles_pane, teams_pane, projects_pane) = (0usize, 1usize, 2usize); + let mut focus: Option<usize> = None; + let mut sel = [0usize; 3]; + // How long each pane was when it was last drawn. The keys are read + // before the frame is built, so walking off the end of a pane has to be + // judged against the length it had a moment ago - which is the length + // the reader is looking at. + let mut pane_len = [0usize; 3]; + // Which pane's selection is open on a screen of its own, and how far + // down it is scrolled. + let (mut detail, mut dscroll): (Option<usize>, usize) = (None, 0); + // Which project is under the cursor on a team's screen, and which one + // is open a level deeper. + // + // The deeper one is held by id, not by index: the list re-sorts on + // every poll - by status, then progress - so a refresh while the screen + // is up would otherwise swap out the project being read without + // touching the cursor. + let mut pick = 0usize; + let mut deep: Option<String> = None; + let mut pscroll = 0usize; + let held: Arc<Mutex<HashMap<String, (serde_json::Value, f64)>>> = + Arc::new(Mutex::new(HashMap::new())); + let asking: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new())); + let ui_tok = ui_token.clone(); + let ui_quota = Arc::clone("a); + // How long the open screen's own list was when it was last drawn, and + // what the cursor was on - read by the keys, which run before the frame + // that answers them is built. A team's screen lists its projects and a + // cycle's lists its open issues; only one is ever up, so one length and + // one cursor serve both. + let mut list_len = 0usize; + let mut open_project: Option<String> = None; + let mut copy_url: Option<(String, String)> = None; + // What the last copy did, and when - shown for a few seconds on the + // bottom row of whichever screen asked for it. + let (mut note, mut note_at) = (String::new(), 0.0f64); + // How far down the board itself is scrolled, and how tall it was when + // it was last drawn - the keys run before the frame that answers them. + let mut board = 0usize; + let mut board_len = 0usize; + // Which project the board's own list has under its cursor. + let mut board_project: Option<String> = None; + let mut tick = 0usize; + let mut settle_t = 0usize; + let mut settle_from: Option<(Vec<f64>, Vec<f64>)> = None; + + loop { + tick += 1; + for key in keyboard.poll() { + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "r" | "R" => { + if let Ok(mut g) = held.lock() { + g.clear(); + } + let (lock, cond) = &*wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + } + "w" | "W" => { + if let Ok(mut want) = days.lock() { + *want = tc::cycle(WINDOWS, *want); + } + let (lock, cond) = &*wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + } + // The rule every widget here with focusable sections + // follows. tab moves to the next pane and, from the last + // one, back to no focus at all - it used to toggle between + // the two for ever, so there was no way to put the cursor + // away. Empty panes are stepped over: focusing one leaves + // the arrows moving an index nothing is drawn from, which + // is a key that does nothing and says nothing. + "tab" => { + focus = tc::next_section(focus, &pane_len); + // Back to no focus means back to reading the board, + // and the window stays where the cursor left it rather + // than jumping to the top. + pick = 0; + } + // Enter opens whichever pane has the cursor. Without a + // focused pane there is nothing selected to open, which is + // the same rule the board's own cursor follows. + // A team's screen opens one of its projects; the board + // opens whichever pane has the cursor. Without a focused + // pane there is nothing selected to open, which is the + // same rule the board's own cursor follows. + "right" | "enter" if detail == Some(teams_pane) && deep.is_none() => { + if let Some(id) = open_project.clone() { + deep = Some(id); + pscroll = 0; + } + } + "right" | "enter" if detail.is_some() => {} + "right" | "enter" => { + if focus.is_some() { + detail = focus; + dscroll = 0; + pick = 0; + // The board's project list has no screen of its + // own between the board and a project. + if focus == Some(projects_pane) { + deep = board_project.clone(); + pscroll = 0; + if deep.is_none() { + detail = None; + } + } + } + } + // Back one level at a time: out of a project to the team + // that owns it, and only then to the board. + "left" | "esc" if deep.is_some() => { + deep = None; + // A project opened from the board's own list has no + // team screen behind it to come back to. + if detail == Some(projects_pane) { + detail = None; + } + } + "left" | "esc" if detail.is_some() => detail = None, + // A team's screen hands the arrows to its project list and + // scrolls itself to follow; every other screen has nothing + // to select, so they scroll outright. + // The issue under the cursor, by its address. An issue is + // a leaf here - there is no screen below it - so what it + // offers is somewhere to go and read it. + "c" | "C" if copy_url.is_some() => { + let (url, ident) = copy_url.clone().unwrap_or_default(); + (note, note_at) = if url.is_empty() { + ("that issue has no url".to_string(), now()) + } else if tc::clipboard(&url) { + (format!("copied {}", ident), now()) + } else { + // Said out loud: a copy that silently did nothing + // is indistinguishable from one that worked until + // the paste comes up empty. + ("could not reach the clipboard".to_string(), now()) + }; + } + "up" | "down" if detail.is_some() && deep.is_none() && list_len > 0 => { + if key == "down" { + pick = pick.saturating_add(1).min(list_len.saturating_sub(1)); + } else { + pick = pick.saturating_sub(1); + } + } + "pgup" | "pgdn" if detail.is_some() && deep.is_none() && list_len > 0 => { + let page = tc::size().1.saturating_sub(3).max(1); + pick = if key == "pgdn" { + pick.saturating_add(page).min(list_len.saturating_sub(1)) + } else { + pick.saturating_sub(page) + }; + } + // Walking off either end of a pane leaves it. There is no + // screen scroll here to hand the arrows to - both panes + // window themselves to fit - so from nothing focused they + // step back in at the near end, the same ring latency and + // link use. + "up" | "down" if detail.is_some() => { + let at = if deep.is_some() { &mut pscroll } else { &mut dscroll }; + if key == "down" { + *at = at.saturating_add(1); + } else { + *at = at.saturating_sub(1); + } + } + "pgup" | "pgdn" if detail.is_some() => { + let page = tc::size().1.saturating_sub(3).max(1); + let at = if deep.is_some() { &mut pscroll } else { &mut dscroll }; + *at = if key == "pgdn" { + at.saturating_add(page) + } else { + at.saturating_sub(page) + }; + } + "up" | "down" => { + let down = key == "down"; + pick = 0; + match focus { + Some(here) => { + focus = tc::step_across_sections(here, sel[here], &pane_len, down) + .map(|(pane, row)| { + sel[pane] = row; + pane + }); + } + // Nothing focused: the arrows move the board. The + // board is taller than the pane and every section + // is drawn whole, so this is the only way to reach + // the bottom of it without picking a section first. + None => { + board = if down { + board.saturating_add(1) + } else { + board.saturating_sub(1) + }; + } + } + } + "pgup" | "pgdn" => { + let page = tc::size().1.saturating_sub(3).max(1); + if focus.is_none() { + board = if key == "pgdn" { + board.saturating_add(page).min(board_len.saturating_sub(1)) + } else { + board.saturating_sub(page) + }; + } else if let Some(here) = focus { + // A page through a focused section, clamped to it - + // crossing out of a section is what the single + // arrows are for. + sel[here] = if key == "pgdn" { + sel[here].saturating_add(page).min(pane_len[here].saturating_sub(1)) + } else { + sel[here].saturating_sub(page) + }; + } + } + _ => {} + } + } + + let (w, h) = tc::size(); + let want = days.lock().map(|g| *g).unwrap_or(14); + let s = match state.lock() { + Ok(g) => g, + Err(_) => return, + }; + // The counters describe the window they were fetched for, which is + // not the one the key has just asked for. + let stale = s.window != want; + let left = quota.lock().map(|g| g.requests).unwrap_or(None); + + let mut rows = vec![tc::title("linear ops", w, &p.new)]; + // Where the focused section's cursor landed, so the board can be + // scrolled to keep it on screen. + let mut cursor: Option<usize> = None; + let mut head = vec![ + ( + p.dim.as_str(), + format!(" {} team{}", s.teams.len(), if s.teams.len() == 1 { "" } else { "s" }), + ), + (p.dim.as_str(), format!(" updated {} ago", ago(s.fetched))), + ]; + if let Some(left) = left { + head.push(( + if left > 500 { p.ok.as_str() } else { p.warn.as_str() }, + format!(" {} req left/hr", left), + )); + } + rows.push(tc::seg(&head, w - 1)); + if !s.err.is_empty() { + rows.push(tc::seg(&[(p.bad.as_str(), format!(" ! {}", s.err))], w - 1)); + } + if s.teams.is_empty() { + rows.push(tc::seg(&[(p.dim.as_str(), " collecting…".into())], w - 1)); + drop(s); + while rows.len() < h.saturating_sub(1) { + rows.push(String::new()); + } + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(400)); + continue; + } + + // How long work takes, across every team. It leads the board: it is + // the one figure that says whether the machine is getting faster or + // slower, and it is an aggregate rather than any one team's - which + // the heading has to say, or it reads as whichever team is selected + // below. + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── HOW LONG ── ".into()), + (p.dim.as_str(), "all teams · ".into()), + ( + p.dim.as_str(), + if stale { + "counting…".to_string() + } else { + format!("median of {} completed in {}d", s.lead.len(), want) + }, + ), + ], + w - 1, + )); + let extreme = |label: &str, pair: &Extreme, colour: &str| -> (String, String, String) { + if stale { + return (label.into(), "···".into(), p.dim.clone()); + } + match pair { + None => (label.into(), "--".into(), p.dim.clone()), + Some((hours, ident)) => ( + label.into(), + format!("{} {}", if ident.is_empty() { "?" } else { ident }, dur(Some(*hours))), + colour.to_string(), + ), + } + }; + let dimmed = |value: Option<f64>| -> (String, String) { + if stale { + ("···".into(), p.dim.clone()) + } else { + (dur(value), p.txt.clone()) + } + }; + let (lead_txt, lead_c) = dimmed(median(&s.lead)); + let (cycle_txt, cycle_c) = dimmed(median(&s.cycle_time)); + let cells: Vec<(String, String, String)> = vec![ + ("lead (created→completed)".into(), lead_txt, lead_c), + ("cycle (started→completed)".into(), cycle_txt, cycle_c), + extreme("quickest", &s.quickest, &p.ok), + extreme("slowest", &s.slowest, &p.warn), + extreme("oldest open", &s.oldest_open, &p.bad), + extreme("oldest in progress", &s.oldest_wip, &p.warn), + ]; + let label_w = cells.iter().map(|c| c.0.chars().count()).max().unwrap_or(8); + // Two columns only when a value still gets room for the longest + // thing it holds - an identifier and a duration. Cells are a fixed + // width so a long value cannot push the next column out of line. + let ncols = if (w - 2) / 2 >= label_w + 3 + 15 { 2 } else { 1 }; + let cw = (w - 2) / ncols; + let val_w = cw.saturating_sub(label_w + 3).max(6); + for chunk in cells.chunks(ncols) { + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, value, colour) in chunk { + line.push((p.dim.as_str(), format!(" {} ", tc::pad(label, label_w)))); + line.push((colour.as_str(), tc::pad(value, val_w))); + } + rows.push(tc::seg(&line, w - 1)); + } + rows.push(String::new()); + + let total_open: usize = STATE_ORDER + .iter() + .map(|st| s.states.get(*st).copied().unwrap_or(0)) + .sum(); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPEN ── ".into()), + (p.new.as_str(), format!("{}", total_open)), + (p.dim.as_str(), " issues open".into()), + (p.dim.as_str(), " (any age)".into()), + ( + p.warn.as_str(), + if s.truncated { " truncated".into() } else { String::new() }, + ), + ], + w - 1, + )); + if total_open > 0 { + let parts: Vec<(f64, String)> = STATE_ORDER + .iter() + .filter_map(|st| { + let n = s.states.get(*st).copied().unwrap_or(0); + if n == 0 { + return None; + } + Some((n as f64 / total_open as f64, state_colour(st, &p).to_string())) + }) + .collect(); + let bar = tc::stacked_bar(&parts, w.saturating_sub(3).max(10)); + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, txt) in &bar { + line.push((colour.as_str(), txt.clone())); + } + rows.push(tc::seg(&line, w - 1)); + let mut key: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for st in STATE_ORDER { + let n = s.states.get(*st).copied().unwrap_or(0); + if n == 0 { + continue; + } + key.push((state_colour(st, &p), "▇ ".into())); + key.push((p.txt.as_str(), state_label(st).into())); + key.push(( + p.dim.as_str(), + format!(" {} ({:.0}%) ", n, 100.0 * n as f64 / total_open as f64), + )); + } + rows.push(tc::seg(&key, w - 1)); + } + + rows.push(String::new()); + let mut ranked_cycles = s.cycles.clone(); + ranked_cycles.sort_by(|a, b| { + let (am, al) = churn(a); + let (bm, bl) = churn(b); + am.total_cmp(&bm).then(al.cmp(&bl)) + }); + pane_len[cycles_pane] = ranked_cycles.len(); + if !ranked_cycles.is_empty() { + sel[cycles_pane] = sel[cycles_pane].min(ranked_cycles.len() - 1); + } + let here_now = focus == Some(cycles_pane); + rows.push(tc::seg( + &[ + ( + if here_now { p.accent.as_str() } else { p.lbl.as_str() }, + " ── ACTIVE CYCLES ── ".into(), + ), + (p.dim.as_str(), running_label(s.cycles.len(), s.cycles_capped)), + ( + if here_now { p.accent.as_str() } else { p.dim.as_str() }, + heading_keys(cycles_pane, focus, &pane_len).to_string(), + ), + ], + w - 1, + )); + if s.cycles.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " no cycle is running in any team".into())], + w - 1, + )); + } + for (ci, c) in ranked_cycles.iter().enumerate() { + if here_now && ci == sel[cycles_pane] { + cursor = Some(rows.len()); + } + let scope = last_of(c, "scopeHistory"); + let done = last_of(c, "completedScopeHistory"); + let opened_at = first_of(c, "scopeHistory"); + let left_days = parse(&text(c, "endsAt")) + .map(|ends| (ends - Utc::now().naive_utc()).num_days()); + let frac = if scope > 0.0 { done / scope } else { 0.0 }; + let name = format!( + "{} {}", + match text(&c["team"], "key") { + k if k.is_empty() => "?".to_string(), + k => k, + }, + match text(c, "name") { + n if n.is_empty() => format!("Cycle {}", tidy(c["number"].as_f64().unwrap_or(0.0))), + n => n, + } + ); + let on = focus == Some(cycles_pane) && ci == sel[cycles_pane]; + let tint = if on { tc::bg(38, 56, 76) } else { String::new() }; + let c_of = |colour: &str| { + // Any colour that would not clear AA on this tint is swapped + // for its lighter twin. `dim` was measured first; a review + // found the others after the first fix shipped saying it was + // done, so they are here by measurement rather than by guess. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else if colour == p.bad { + p.bad_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let hot = tc::heat(frac); + let mut line = vec![ + ( + c_of(if on { &p.accent } else { &p.txt }), + format!("{}{}", if on { "▸" } else { " " }, tc::pad(&name, 18)), + ), + ( + c_of(&hot), + tc::meter(frac, (w.saturating_sub(54)).clamp(8, 28)), + ), + ( + c_of(if scope > 0.0 { &hot } else { &p.dim }), + format!( + " {:>3}", + if scope > 0.0 { + format!("{:.0}%", frac * 100.0) + } else { + "--".into() + } + ), + ), + ( + c_of(&p.dim), + if scope > 0.0 { + format!(" {}/{} pts", tidy(done), tidy(scope)) + } else { + " nothing scoped".into() + }, + ), + ]; + if let Some(days_left) = left_days { + line.push(( + c_of(if days_left <= 2 { &p.warn } else { &p.dim }), + format!(" {}d left", days_left), + )); + } + // Scope added after the cycle opened is the number that explains + // a cycle working hard and still slipping. + if scope > opened_at { + line.push((c_of(&p.bad), format!(" +{} added", tidy(scope - opened_at)))); + } + if on { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + + // Arrivals against departures. + let today = Utc::now().date_naive(); + let mut days_list: Vec<String> = (0..want) + .rev() + .map(|n| (today - chrono::Duration::days(n)).format("%Y-%m-%d").to_string()) + .collect(); + let avail = w.saturating_sub(3).max(10); + if days_list.len() > avail { + days_list = days_list[days_list.len() - avail..].to_vec(); + } + let slot = (avail / days_list.len()).max(1); + let gap = if slot >= 3 { 1 } else { 0 }; + let barw = slot - gap; + let spread = |per_day: &[f64]| -> Vec<f64> { + let mut cols = Vec::new(); + for (n, v) in per_day.iter().enumerate() { + cols.extend(std::iter::repeat_n(*v, barw)); + if gap > 0 && n + 1 < per_day.len() { + cols.extend(std::iter::repeat_n(0.0, gap)); + } + } + cols + }; + let made_day: Vec<f64> = days_list + .iter() + .map(|d| s.created.get(d).copied().unwrap_or(0) as f64) + .collect(); + let done_day: Vec<f64> = days_list + .iter() + .map(|d| s.completed.get(d).copied().unwrap_or(0) as f64) + .collect(); + let (up, down) = (spread(&made_day), spread(&done_day)); + let chart_cols = up.len(); + let span_hi = up + .iter() + .chain(down.iter()) + .cloned() + .fold(0.0f64, f64::max) + .max(1.0); + rows.push(String::new()); + if stale { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── ISSUE FLOW ── ".into()), + (p.dim.as_str(), format!("counting {}d…", want)), + ], + w - 1, + )); + } else { + let span = if days_list.len() < want as usize { + format!("{}d of {}d", days_list.len(), want) + } else { + format!("{}d", days_list.len()) + }; + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── ISSUE FLOW ── ".into()), + (p.dim.as_str(), format!("{} · ", span)), + ( + p.new.as_str(), + format!("▲ {} created", made_day.iter().sum::<f64>() as i64), + ), + (p.dim.as_str(), " · ".into()), + ( + p.ok.as_str(), + format!("▼ {} completed", done_day.iter().sum::<f64>() as i64), + ), + (p.dim.as_str(), format!(" peak {}/day", span_hi as i64)), + ], + w - 1, + )); + } + // While the counters are for a window nobody asked for, the chart + // dances rather than showing a number that is not the answer; when + // the real one lands it eases in from where the dance left off. + let (hu, hd, cu, cd) = if stale { + let hu = spread(&tc::dance(days_list.len(), tick, 0.0)); + let hd = spread(&tc::dance(days_list.len(), tick, 2.1)); + settle_from = Some((hu.clone(), hd.clone())); + settle_t = 0; + ( + hu, + hd, + tc::mix(GHOST, NEW_RGB, 0.45), + tc::mix(GHOST, OK_RGB, 0.45), + ) + } else { + let real_u: Vec<f64> = up.iter().map(|v| v / span_hi).collect(); + let real_d: Vec<f64> = down.iter().map(|v| v / span_hi).collect(); + match &settle_from { + Some((fu, fd)) if settle_t < SETTLE_FRAMES && fu.len() == chart_cols => { + settle_t += 1; + let q = settle_t as f64 / SETTLE_FRAMES as f64; + let q = q * q * (3.0 - 2.0 * q); + ( + fu.iter().zip(&real_u).map(|(a, b)| a + (b - a) * q).collect(), + fd.iter().zip(&real_d).map(|(a, b)| a + (b - a) * q).collect(), + tc::mix(GHOST, NEW_RGB, 0.45 + 0.55 * q), + tc::mix(GHOST, OK_RGB, 0.45 + 0.55 * q), + ) + } + _ => (real_u, real_d, p.new.clone(), p.ok.clone()), + } + }; + for line in tc::vbars( + &hu.iter().map(|v| (*v, cu.clone())).collect::<Vec<_>>(), + 3, + 1.0, + ) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + rows.push(tc::seg( + &[(tc::RST, " ".into()), (p.grid.as_str(), "─".repeat(chart_cols))], + w - 1, + )); + for line in tc::vbars_down( + &hd.iter().map(|v| (*v, cd.clone())).collect::<Vec<_>>(), + 3, + 1.0, + ) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + let left_lbl = format!("{}d ago", days_list.len()); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", left_lbl)), + ( + p.dim.as_str(), + " ".repeat(chart_cols.saturating_sub(left_lbl.chars().count() + 5).max(1)), + ), + (p.dim.as_str(), "today".into()), + ], + w - 1, + )); + + rows.push(String::new()); + let mut ranked = s.teams.clone(); + ranked.sort_by(|a, b| { + let open = |k: &String| { + s.by_team + .get(k) + .and_then(|c| c.get("open")) + .copied() + .unwrap_or(0) + }; + open(&b.0).cmp(&open(&a.0)).then(a.0.cmp(&b.0)) + }); + pane_len[teams_pane] = ranked.len(); + if !ranked.is_empty() { + sel[teams_pane] = sel[teams_pane].min(ranked.len() - 1); + } + let on_teams = focus == Some(teams_pane); + rows.push(tc::seg( + &[ + ( + if on_teams { p.accent.as_str() } else { p.lbl.as_str() }, + " ── BY TEAM ──".into(), + ), + ( + if on_teams { p.accent.as_str() } else { p.dim.as_str() }, + heading_keys(teams_pane, focus, &pane_len).to_string(), + ), + ], + w - 1, + )); + rows.push(tc::seg( + &[( + p.dim.as_str(), + tc::pad( + &format!( + " {:<22}{:>6}{:>7}{:>8}{:>8}", + "TEAM", + "OPEN", + "TRIAGE", + "DOING", + format!("DONE{}D", want) + ), + w - 1, + ), + )], + w - 1, + )); + for (i, (key, name)) in ranked.iter().enumerate() { + if on_teams && i == sel[teams_pane] { + cursor = Some(rows.len()); + } + let empty = HashMap::new(); + let c = s.by_team.get(key).unwrap_or(&empty); + let count = |k: &str| c.get(k).copied().unwrap_or(0); + let here = on_teams && i == sel[teams_pane]; + let tint = if here { tc::bg(38, 56, 76) } else { String::new() }; + let c_of = |colour: &str| { + // Any colour that would not clear AA on this tint is swapped + // for its lighter twin. `dim` was measured first; a review + // found the others after the first fix shipped saying it was + // done, so they are here by measurement rather than by guess. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else if colour == p.bad { + p.bad_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let mut line = vec![ + ( + c_of(if here { &p.accent } else { &p.txt }), + format!( + "{}{}", + if here { "▸" } else { " " }, + tc::pad(&format!("{} {}", key, name), 22) + ), + ), + (c_of(&p.new), format!("{:>6}", count("open"))), + ( + c_of(if count("triage") > 0 { &p.bad } else { &p.dim }), + format!("{:>7}", count("triage")), + ), + ( + c_of(if count("started") > 0 { &p.warn } else { &p.dim }), + format!("{:>8}", count("started")), + ), + ( + c_of(if count("done") > 0 { &p.ok } else { &p.dim }), + format!("{:>8}", count("done")), + ), + ]; + if here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + // Every project that is still going, whichever team owns it. The + // board reaches them without going through a team first, the way + // it reaches cycles without going through one. + rows.push(String::new()); + let live: Vec<Proj> = s + .all_projects + .iter() + .filter(|q| q.kind != "completed" && q.kind != "canceled") + .cloned() + .collect(); + let finished = s.all_projects.len() - live.len(); + pane_len[projects_pane] = live.len(); + if !live.is_empty() { + sel[projects_pane] = sel[projects_pane].min(live.len() - 1); + } + board_project = live.get(sel[projects_pane]).map(|q| q.id.clone()); + let on_projects = focus == Some(projects_pane); + rows.push(tc::seg( + &[ + ( + if on_projects { p.accent.as_str() } else { p.lbl.as_str() }, + " ── PROJECTS ── ".into(), + ), + // What is not in the list, because a count of the running + // ones alone reads as a count of all of them. + ( + p.dim.as_str(), + if finished > 0 { + format!("{} running · {} finished, not listed", live.len(), finished) + } else { + format!("{} running", live.len()) + }, + ), + ( + if on_projects { p.accent.as_str() } else { p.dim.as_str() }, + heading_keys(projects_pane, focus, &pane_len).to_string(), + ), + ], + w - 1, + )); + if live.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " no project is running in any team".into())], + w - 1, + )); + } + { + // Sized the way the team screen sizes its own list: no column + // capped, the bar taking what is left, and the aside shedding + // whole facts rather than being cut through the middle. + let asides: Vec<String> = live + .iter() + .map(|q| { + let open: usize = + s.proj_state.get(&q.id).map(|m| m.values().sum()).unwrap_or(0); + if open > 0 { + format!("{} open", open) + } else { + String::new() + } + }) + .collect(); + let widest = |xs: &mut dyn Iterator<Item = usize>| xs.max().unwrap_or(0); + let team_w = widest(&mut live.iter().map(|q| q.teams.join("/").chars().count())).max(4); + let name_w = widest(&mut live.iter().map(|q| q.name.chars().count())).max(8); + let label_w = widest(&mut live.iter().map(|q| q.label.chars().count())).max(4); + let full = widest(&mut asides.iter().map(|a| a.chars().count())); + // Something has to go at narrow widths: the longest project + // name in this workspace is 47 characters, and at eighty + // columns the name, the team and the status alone do not fit. + let base = 2 + team_w + 2 + name_w + 5; + let (label_cost, bar_w, room) = project_columns(w, base, label_w, full); + for (i, (q, aside)) in live.iter().zip(&asides).enumerate() { + let here = on_projects && i == sel[projects_pane]; + if here { + cursor = Some(rows.len()); + } + let tint = if here { tc::bg(38, 56, 76) } else { String::new() }; + let c_of = |colour: &str| { + // Any colour that would not clear AA on this tint is swapped + // for its lighter twin. `dim` was measured first; a review + // found the others after the first fix shipped saying it was + // done, so they are here by measurement rather than by guess. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else if colour == p.bad { + p.bad_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let colour = match q.kind.as_str() { + "started" => p.warn.as_str(), + _ => p.txt.as_str(), + }; + let aside = if aside.chars().count() <= room { aside.as_str() } else { "" }; + let aside = + if aside.is_empty() { String::new() } else { format!(" {}", aside) }; + let line: Vec<(String, String)> = vec![ + ( + c_of(if here { &p.accent } else { &p.dim }), + format!( + "{} {}", + if here { "▸" } else { " " }, + tc::pad(&q.teams.join("/"), team_w) + ), + ), + ( + c_of(if here { &p.accent } else { &p.txt }), + format!(" {}", tc::pad(&q.name, name_w)), + ), + ( + c_of(colour), + if label_cost > 0 { + format!(" {}", tc::pad(&q.label, label_w)) + } else { + String::new() + }, + ), + ( + c_of(colour), + if bar_w > 0 { + format!(" {}", tc::meter(q.progress, bar_w)) + } else { + String::new() + }, + ), + (c_of(&p.txt), format!(" {:>3.0}%", 100.0 * q.progress)), + (c_of(&p.dim), aside), + ]; + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + } + + // One cycle, one team, or one of that team's projects, each on a + // screen of its own. + if let Some(which) = detail { + let team = ranked + .get(sel[teams_pane].min(ranked.len().saturating_sub(1))) + .cloned(); + let none: Vec<Proj> = Vec::new(); + let projects: Vec<Proj> = team + .as_ref() + .and_then(|(key, _)| s.projects.get(key)) + .unwrap_or(&none) + .clone(); + if which == teams_pane { + list_len = projects.len(); + pick = pick.min(list_len.saturating_sub(1)); + open_project = projects.get(pick).map(|q| q.id.clone()); + } + // The project being read is found by its id, so a poll that + // re-sorts the list underneath cannot swap it for its + // neighbour. + // Looked up across the whole workspace, because the project + // may have been opened from the board's list rather than from + // the team whose screen is behind it. + let reading = deep + .as_ref() + .and_then(|id| s.all_projects.iter().find(|q| &q.id == id).cloned()); + let (body, cursor) = if let Some(q) = reading.as_ref() { + let empty = HashMap::new(); + let states = s.proj_state.get(&q.id).unwrap_or(&empty); + let oldest = s.proj_oldest.get(&q.id).cloned(); + let record = held.lock().ok().and_then(|g| g.get(&q.id).map(|(v, _)| v.clone())); + ( + project_detail( + q, + &q.teams.join(" · "), + record.as_ref(), + states, + oldest.as_ref(), + w, + &p, + ), + None, + ) + } else if which == cycles_pane { + ranked_cycles + .get(sel[cycles_pane].min(ranked_cycles.len().saturating_sub(1))) + .map(|c| { + let id = text(c, "id"); + let empty = HashMap::new(); + let none: Vec<Issue> = Vec::new(); + let states = s.cycle_state.get(&id).unwrap_or(&empty); + let issues = s.cycle_issues.get(&id).unwrap_or(&none); + list_len = issues.len(); + copy_url = issues.get(pick.min(issues.len().saturating_sub(1))) + .map(|i| (i.url.clone(), i.ident.clone())); + cycle_detail(c, states, issues, pick, w, h, &p) + }) + .unwrap_or_default() + } else { + team.as_ref() + .map(|(key, name)| { + let empty = HashMap::new(); + let counts = s.by_team.get(key).unwrap_or(&empty); + let opens = s.proj_open.get(key).unwrap_or(&empty); + team_detail(key, name, counts, &projects, opens, pick, s.window, w, h, &p) + }) + .unwrap_or_default() + }; + drop(s); + + // Ask for the open project's own record, once, and again once + // it has gone stale - the screen is left up for minutes at a + // time and a burn-up from ten minutes ago is not this one. + if let Some(q) = reading.as_ref() { + let due = held + .lock() + .ok() + .and_then(|g| g.get(&q.id).map(|(_, at)| now() - at > refresh)) + .unwrap_or(true); + let mine = asking.lock().map(|g| !g.contains(&q.id)).unwrap_or(false); + if due && mine && !ui_tok.is_empty() { + if let Ok(mut g) = asking.lock() { + g.insert(q.id.clone()); + } + let (id, tok, quota) = (q.id.clone(), ui_tok.clone(), Arc::clone(&ui_quota)); + let (held, asking) = (Arc::clone(&held), Arc::clone(&asking)); + std::thread::spawn(move || { + let got = fetch_project(&id, &tok, "a); + if let Ok(mut g) = held.lock() { + g.insert(id.clone(), (got, now())); + } + if let Ok(mut g) = asking.lock() { + g.remove(&id); + } + }); + } + } + + if body.is_empty() { + detail = None; + deep = None; + } else { + // What this screen's own list is called, when it has one: + // a team's holds its projects and a cycle's its open + // issues. A screen with an empty list hands the arrows + // back to plain scroll and says so. + let listing = if reading.is_some() || list_len == 0 { + None + } else if which == teams_pane { + Some(" project") + } else if which == cycles_pane { + Some(" issue") + } else { + None + }; + let mut hints: Vec<Vec<(&str, String)>> = vec![vec![ + (p.accent.as_str(), "↑↓".into()), + (p.dim.as_str(), listing.unwrap_or(" scroll").to_string()), + ]]; + // Only a project can be opened; an issue is a leaf, and + // what a leaf offers here is its address. + if listing.is_some() && which == teams_pane { + hints.push(vec![ + (p.accent.as_str(), "→/↵".into()), + (p.dim.as_str(), " open it".into()), + ]); + } + if copy_url.is_some() { + hints.push(vec![(p.dim.as_str(), "[c]opy url".into())]); + } + hints.push(vec![ + (p.accent.as_str(), "←".into()), + ( + p.dim.as_str(), + // Back goes wherever this screen was opened from, + // and says which: a project reached through a team + // returns to that team, one reached from the + // board's own list returns to the board. + if reading.is_some() && which == teams_pane { + "/esc the team".to_string() + } else { + "/esc back".to_string() + }, + ), + ]); + hints.push(vec![(p.dim.as_str(), "[r]efresh".into())]); + hints.push(vec![(p.dim.as_str(), "[q]uit".into())]); + let foot: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + let room = h.saturating_sub(foot.len()).max(1); + let at = if reading.is_some() { &mut pscroll } else { &mut dscroll }; + // The page follows the cursor into the project list, the + // way netwatch's detail follows one into a section. + if let Some(row) = cursor { + *at = tc::follow(*at, row, room); + } + *at = (*at).min(body.len().saturating_sub(room)); + let from = *at; + let last = (from + room).min(body.len()); + let mut out: Vec<String> = body[from..last].to_vec(); + while out.len() < room { + out.push(String::new()); + } + if !note.is_empty() && now() - note_at < 6.0 { + if let Some(row) = out.last_mut() { + *row = tc::seg(&[(p.ok.as_str(), format!(" {}", note))], w - 1); + } + } + out.extend(foot); + tc::draw(&out, w, h); + std::thread::sleep(Duration::from_millis(300)); + continue; + } + } else { + drop(s); + list_len = 0; + open_project = None; + copy_url = None; + deep = None; + } + + // The board is taller than the pane, so the arrows do one of two + // things and the footer has to say which. Unfocused they move the + // whole board; focused they move a cursor through one section, and + // the board follows. + let mut hints: Vec<Vec<(&str, String)>> = vec![ + vec![ + (p.accent.as_str(), "↑↓".into()), + ( + p.dim.as_str(), + if focus.is_some() { " select" } else { " scroll" }.to_string(), + ), + ], + vec![ + (p.accent.as_str(), "tab".into()), + ( + p.dim.as_str(), + if focus.is_some() { " next section" } else { " into a section" }.to_string(), + ), + ], + ]; + if focus.is_some() { + hints.push(vec![ + (p.accent.as_str(), "→/↵".into()), + (p.dim.as_str(), " open it".into()), + ]); + } + hints.push(vec![(p.dim.as_str(), "pgup/pgdn page".into())]); + hints.push(vec![(p.dim.as_str(), "[w]indow".into())]); + hints.push(vec![(p.dim.as_str(), "[r]efresh".into())]); + hints.push(vec![(p.dim.as_str(), "[q]uit".into())]); + let footer: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + // The board is longer than most panes are tall, and every section + // is now drawn whole - so the frame is a window onto it. With a + // section focused the window chases its cursor; with none, the + // arrows move the window itself. + let room = h.saturating_sub(footer.len()).max(1); + if let Some(at) = cursor { + board = tc::follow(board, at, room); + } + board = board.min(rows.len().saturating_sub(room)); + board_len = rows.len(); + let last = (board + room).min(rows.len()); + let mut out: Vec<String> = rows[board..last].to_vec(); + while out.len() < room { + out.push(String::new()); + } + out.extend(footer); + tc::draw(&out, w, h); + std::thread::sleep(Duration::from_millis(300)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn a_project(id: &str, name: &str, label: &str, kind: &str, progress: f64) -> Proj { + Proj { + id: id.into(), + name: name.into(), + label: label.into(), + kind: kind.into(), + progress, + ..Default::default() + } + } + + /// Which screen column a string starts at. + /// + /// `str::find` answers in bytes, and the cursor marker is three of them + /// for one column - so two rows that line up on screen came back two + /// apart and failed a test about alignment. + fn col(line: &str, needle: &str) -> usize { + let at = line.find(needle).unwrap_or_else(|| panic!("{:?} not in {:?}", needle, line)); + line[..at].chars().count() + } + + /// A team's screen as plain text. `pick` is the project under the + /// cursor, which every test but the cursor's own leaves at the first. + fn team_rows( + counts: &HashMap<String, usize>, + projects: &[Proj], + opens: &HashMap<String, usize>, + w: usize, + ) -> String { + let (rows, _) = + team_detail("ABC", "A Team", counts, projects, opens, 0, 14, w, 40, &palette()); + plain(&rows) + } + + /// The plain text of a rendered row, with the colour escapes taken out. + fn plain(rows: &[String]) -> String { + let joined = rows.join("\n"); + let mut out = String::new(); + let mut chars = joined.chars(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + for c in chars.by_ref() { + if c == 'm' { + break; + } + } + } else { + out.push(c); + } + } + out + } + + fn an_issue(ident: &str, title: &str, state: &str, age: f64, points: Option<f64>) -> Issue { + Issue { + ident: ident.into(), + title: title.into(), + url: format!("https://linear.app/x/issue/{}", ident), + state: state.into(), + age, + points, + } + } + + /// A cycle carrying the two histories its screen reads. + fn a_cycle(total: f64, closed: f64) -> serde_json::Value { + serde_json::json!({ + "number": 7, "name": "", "endsAt": "2026-09-06T00:00:00.000Z", + "team": { "name": "A Team" }, + "scopeHistory": [10.0, 10.0], "completedScopeHistory": [0.0, 2.0], + "issueCountHistory": [total, total], "completedIssueCountHistory": [closed, closed], + }) + } + + #[test] + fn a_cycle_lists_what_is_open_in_it_and_counts_what_is_not() { + // The scan behind this fetches open issues only, so a cycle of + // eight with seven closed has one row - and without saying so, a + // heading reading "1" under "issues 8" reads as a bug. + let issues = vec![an_issue("ABC-1", "a thing", "started", 30.0, Some(3.0))]; + let states: HashMap<String, usize> = [("started".to_string(), 1usize)].into_iter().collect(); + let (rows, at) = cycle_detail(&a_cycle(8.0, 7.0), &states, &issues, 0, 110, 40, &palette()); + let out = plain(&rows); + assert!(out.contains("OPEN IN THIS CYCLE"), "{}", out); + assert!(out.contains("7 closed, not listed"), "{}", out); + assert!(out.contains("ABC-1"), "{}", out); + assert!(out.contains("a thing"), "{}", out); + // The state's own vocabulary, the same word the board uses. + assert!(out.contains("in progress"), "{}", out); + assert!(out.contains("3p"), "{}", out); + // And a cursor, on the row it named. + assert!(plain(&[rows[at.unwrap()].clone()]).contains("ABC-1")); + assert_eq!(out.matches('▸').count(), 1); + } + + #[test] + fn a_cycle_with_nothing_open_says_so_and_offers_no_cursor() { + let (rows, at) = + cycle_detail(&a_cycle(4.0, 4.0), &HashMap::new(), &[], 0, 110, 40, &palette()); + let out = plain(&rows); + assert!(out.contains("nothing open in it"), "{}", out); + // No cursor means the arrows go back to scrolling the screen, and + // the footer says "scroll" rather than naming a list to walk. + assert!(at.is_none()); + } + + #[test] + fn a_long_issue_title_carries_on_underneath_rather_than_being_cut() { + // The shape that broke it: a title built from a long chain of + // separated parts, far past any column a list can give it. + let long = "Check every published rate · northern route · via the interchange \ + stop · against the operator's own timetable"; + let issues = vec![an_issue("ABC-1", long, "started", 30.0, None)]; + let states: HashMap<String, usize> = [("started".to_string(), 1usize)].into_iter().collect(); + let (rows, _) = cycle_detail(&a_cycle(1.0, 0.0), &states, &issues, 0, 100, 40, &palette()); + let out = plain(&rows); + // Every word of it is on screen, even though no single row is wide + // enough to hold the title. + for word in long.split_whitespace() { + assert!(out.contains(word), "{:?} missing from:\n{}", word, out); + } + // The issue rows fit in the pane. Measured against `w`, not + // `w - 1`: the heading bar pads to the full width and the rows do + // not, so a single bound for both would pass on the wrong one. + assert!( + out.lines().all(|l| l.chars().count() <= 100), + "a row ran past the pane: {:?}", + out.lines().max_by_key(|l| l.chars().count()) + ); + // An issue nobody has pointed says nothing, not "0p". + assert!(!out.contains("0p"), "{}", out); + } + + #[test] + fn a_cycles_issues_put_what_is_moving_first_and_the_oldest_of_those_first() { + let mut issues = vec![ + an_issue("ABC-3", "newer wip", "started", 10.0, None), + an_issue("ABC-1", "waiting", "unstarted", 900.0, None), + an_issue("ABC-2", "older wip", "started", 500.0, None), + an_issue("ABC-4", "unlooked at", "triage", 999.0, None), + an_issue("ABC-5", "shelved", "backlog", 999.0, None), + ]; + issues.sort_by(|a, b| { + state_rank(&a.state) + .cmp(&state_rank(&b.state)) + .then(b.age.total_cmp(&a.age)) + .then(a.ident.cmp(&b.ident)) + }); + let order: Vec<&str> = issues.iter().map(|i| i.ident.as_str()).collect(); + assert_eq!(order, ["ABC-2", "ABC-3", "ABC-1", "ABC-5", "ABC-4"]); + } + + #[test] + fn a_team_screen_lists_its_projects_with_their_own_progress() { + let counts: HashMap<String, usize> = + [("open".to_string(), 9usize), ("started".to_string(), 4)].into_iter().collect(); + let projects = vec![ + a_project("p1", "hallway-lights", "In Progress", "started", 0.5), + a_project("p2", "old-thing", "Done", "completed", 1.0), + ]; + let opens: HashMap<String, usize> = [("p1".to_string(), 4usize)].into_iter().collect(); + let out = team_rows(&counts, &projects, &opens, 100); + assert!(out.contains("PROJECTS"), "{}", out); + assert!(out.contains("hallway-lights"), "{}", out); + assert!(out.contains("4 open"), "{}", out); + // Linear's own figure, not one derived from the open issues: this + // project has four of nine open and is still shown at 50%. + assert!(out.contains(" 50%"), "{}", out); + // A finished project keeps its place and says so, rather than + // showing a full bar with no explanation. + assert!(out.contains("Done"), "{}", out); + assert!(out.contains("100%"), "{}", out); + } + + #[test] + fn open_issues_in_no_project_are_counted_out_loud() { + // Nine open, four in a project: the other five are in none, and + // without saying so the column below does not reconcile with the + // team's own open count three lines above it. + let counts: HashMap<String, usize> = [("open".to_string(), 9usize)].into_iter().collect(); + let projects = vec![a_project("p1", "hallway-lights", "In Progress", "started", 0.5)]; + let opens: HashMap<String, usize> = + [("p1".to_string(), 4usize), (String::new(), 5)].into_iter().collect(); + let out = team_rows(&counts, &projects, &opens, 100); + assert!(out.contains("5 open in no project"), "{}", out); + + // With none loose, the aside is not there to be read past. + let opens: HashMap<String, usize> = [("p1".to_string(), 4usize)].into_iter().collect(); + let out = team_rows(&counts, &projects, &opens, 100); + assert!(!out.contains("in no project"), "{}", out); + } + + #[test] + fn a_long_project_name_is_shown_whole_and_the_bar_gives_way() { + // The first cut of this capped the name column at 34 characters, + // and a real project came out as "GCP Optimisations and GKE + // Migratio" - which reads as a project that does not exist. + let long = "GCP Optimisations and GKE Migration"; + let counts: HashMap<String, usize> = [("open".to_string(), 2usize)].into_iter().collect(); + let projects = vec![ + a_project("p1", long, "Completed", "completed", 0.87), + a_project("p2", "short", "In Progress", "started", 0.1), + ]; + let out = + team_rows(&counts, &projects, &HashMap::new(), 130); + assert!(out.contains(long), "{}", out); + // And the short one still lines up under it. + let row = out.lines().find(|l| l.contains("short")).unwrap(); + let wide = out.lines().find(|l| l.contains(long)).unwrap(); + assert_eq!( + col(row, "In Progress"), + col(wide, "Completed"), + "status column ragged:\n{}\n{}", + wide, + row + ); + } + + #[test] + fn a_narrow_pane_drops_whole_facts_off_the_aside_rather_than_half_of_one() { + let counts: HashMap<String, usize> = [("open".to_string(), 2usize)].into_iter().collect(); + let mut q = a_project("p1", "a-project", "In Progress", "started", 0.5); + q.target = "2024-07-26".into(); + q.lead = "Wilhelmina".into(); + let opens: HashMap<String, usize> = [("p1".to_string(), 2usize)].into_iter().collect(); + let wide = team_rows(&counts, &[q.clone()], &opens, 130); + let row = wide.lines().find(|l| l.contains("a-project")).unwrap(); + assert!(row.contains("2 open · due 2024-07-26 · Wilhelmina"), "{}", row); + + // Squeezed, the last fact leaves whole. What is left is still true, + // and no half-written name is on screen claiming to be someone. + for w in [58usize, 64, 70, 76, 82] { + let out = team_rows(&counts, &[q.clone()], &opens, w); + let row = out.lines().find(|l| l.contains("a-project")).unwrap(); + let aside = row.split("50%").nth(1).unwrap().trim(); + assert!( + ["", "2 open", "2 open · due 2024-07-26", "2 open · due 2024-07-26 · Wilhelmina"] + .contains(&aside), + "w={} left a part cut in half: {:?}", + w, + aside + ); + } + } + + #[test] + fn the_cursor_marks_one_project_and_says_which_row_it_is_on() { + let counts: HashMap<String, usize> = [("open".to_string(), 3usize)].into_iter().collect(); + let projects = vec![ + a_project("p1", "first", "In Progress", "started", 0.1), + a_project("p2", "second", "In Progress", "started", 0.2), + a_project("p3", "third", "In Progress", "started", 0.3), + ]; + let (rows, at) = + team_detail("ABC", "A", &counts, &projects, &HashMap::new(), 1, 14, 100, 40, &palette()); + let at = at.expect("a project list has a cursor"); + assert!(plain(&[rows[at].clone()]).contains("second"), "{}", plain(&[rows[at].clone()])); + // Exactly one marker, so the reader is never asked which of two is + // the one enter would open. + assert_eq!(plain(&rows).matches('▸').count(), 1); + + // A cursor past the end lands on the last project rather than + // falling off it - the list shortens under the cursor whenever a + // poll lands. + let (rows, at) = + team_detail("ABC", "A", &counts, &projects, &HashMap::new(), 99, 14, 100, 40, &palette()); + assert!(plain(&[rows[at.unwrap()].clone()]).contains("third")); + } + + #[test] + fn a_project_screen_is_found_by_id_so_a_resort_cannot_swap_it() { + // This is the whole reason the open project is held by id. The + // list re-sorts on every poll; holding an index would have left + // the screen showing whichever project fell into that slot. + let mut projects = vec![ + a_project("p1", "first", "In Progress", "started", 0.1), + a_project("p2", "second", "In Progress", "started", 0.2), + ]; + let open = "p2".to_string(); + let before = projects.iter().find(|q| q.id == open).cloned().unwrap(); + projects.reverse(); + let after = projects.iter().find(|q| q.id == open).cloned().unwrap(); + assert_eq!(before.name, after.name); + assert_eq!(after.name, "second"); + // And the index that used to point at it now points elsewhere. + assert_eq!(projects[1].name, "first"); + } + + #[test] + fn a_project_screen_shows_what_it_has_before_the_rest_arrives() { + let q = a_project("p1", "hallway-lights", "In Progress", "started", 0.5); + let states: HashMap<String, usize> = + [("started".to_string(), 2usize), ("triage".to_string(), 1)].into_iter().collect(); + // Nothing fetched yet: the name, status and progress the list + // already knew are on the first frame, and the screen says a + // request is out rather than looking empty. + let out = plain(&project_detail(&q, "ABC", None, &states, None, 100, &palette())); + assert!(out.contains("HALLWAY-LIGHTS · ABC"), "{}", out); + assert!(out.contains(" 50%"), "{}", out); + assert!(out.contains("In Progress"), "{}", out); + assert!(out.contains("OPEN BY STATE"), "{}", out); + assert!(out.contains("asking Linear"), "{}", out); + } + + #[test] + fn a_finished_project_is_not_called_late() { + // It read "overdue by 760d" on a project completed two years ago, + // because nothing was asking whether the work had since landed. + let mut q = a_project("p1", "old-thing", "Completed", "completed", 1.0); + q.target = "2024-07-26".into(); + let record = serde_json::json!({ "completedAt": "2024-08-01T00:00:00.000Z" }); + let out = + plain(&project_detail(&q, "ABC", Some(&record), &HashMap::new(), None, 100, &palette())); + assert!(!out.contains("overdue"), "{}", out); + assert!(out.contains("2024-08-01"), "{}", out); + assert!(out.contains("target was"), "{}", out); + + // Still running and past its date, it is late and says so. + let mut q = a_project("p2", "live-thing", "In Progress", "started", 0.4); + q.target = "2024-07-26".into(); + let out = + plain(&project_detail(&q, "ABC", Some(&serde_json::json!({})), &HashMap::new(), None, 100, &palette())); + assert!(out.contains("overdue by"), "{}", out); + } + + #[test] + fn a_project_fetch_that_failed_says_so_instead_of_asking_for_ever() { + // A screen stuck on "loading" is this widget's oldest lesson: a + // failure nobody records is indistinguishable from a slow answer. + let q = a_project("p1", "hallway-lights", "In Progress", "started", 0.5); + let bad = serde_json::json!({ "_error": "HTTP 502 from Linear" }); + let out = + plain(&project_detail(&q, "ABC", Some(&bad), &HashMap::new(), None, 100, &palette())); + assert!(out.contains("could not read the project"), "{}", out); + assert!(out.contains("HTTP 502"), "{}", out); + assert!(!out.contains("asking Linear"), "{}", out); + } + + #[test] + fn a_page_of_milestones_is_not_reported_as_every_milestone() { + // The same rule one heading down. MILESTONES printed the length of + // the page it was given, so a project with more than the twenty-five + // asked for read as a project with exactly twenty-five. + let q = a_project("p1", "hallway-lights", "In Progress", "started", 0.5); + let stones = |more: bool| { + serde_json::json!({ + "projectMilestones": { + "nodes": [ + { "name": "kick-off", "targetDate": "2026-09-03", "progress": 100.0 }, + { "name": "cutover", "targetDate": "2026-09-05", "progress": 0.0 }, + ], + "pageInfo": { "hasNextPage": more }, + }, + }) + }; + let shown = |v: &serde_json::Value| { + plain(&project_detail(&q, "ABC", Some(v), &HashMap::new(), None, 100, &palette())) + }; + let out = shown(&stones(true)); + assert!(out.contains("MILESTONES ── 2+"), "a page that did not end is a floor:\n{}", out); + let out = shown(&stones(false)); + assert!(out.contains("MILESTONES ── 2"), "{}", out); + assert!(!out.contains("2+"), "nothing is missing, so nothing is marked:\n{}", out); + + // A record from before the flag was asked for reads as a plain total + // rather than as suspect - the same allowance the members row makes. + let old = serde_json::json!({ + "projectMilestones": { "nodes": [ + { "name": "kick-off", "targetDate": "2026-09-03", "progress": 100.0 }, + ]}, + }); + assert!(!shown(&old).contains("1+"), "no flag is not the same as a flag saying more"); + } + + #[test] + fn a_page_of_members_is_not_reported_as_every_member() { + let q = a_project("p1", "hallway-lights", "In Progress", "started", 0.5); + // Linear hands back a page and says whether there are more. The row + // used to count the page and print the figure as the project's + // membership, which for a project with more members than a page + // holds is a wrong number, not a rounded one. + let record = serde_json::json!({ + "members": { + "nodes": [ { "name": "ada" }, { "name": "grace" } ], + "pageInfo": { "hasNextPage": true }, + }, + }); + let out = + plain(&project_detail(&q, "ABC", Some(&record), &HashMap::new(), None, 100, &palette())); + assert!(out.contains("2+"), "a page that did not end is a floor:\n{}", out); + assert!(out.contains("ada, grace, …"), "and the list says so too:\n{}", out); + + // The connection ended, so the count is the count. + let record = serde_json::json!({ + "members": { + "nodes": [ { "name": "ada" }, { "name": "grace" } ], + "pageInfo": { "hasNextPage": false }, + }, + }); + let out = + plain(&project_detail(&q, "ABC", Some(&record), &HashMap::new(), None, 100, &palette())); + assert!(!out.contains("2+"), "nothing is missing, so nothing is marked:\n{}", out); + assert!(out.contains("ada, grace"), "{}", out); + assert!(!out.contains('…'), "{}", out); + + // A record with no pageInfo at all - an older shape, or a fixture - + // reads as complete rather than as suspect. + let record = serde_json::json!({ "members": { "nodes": [ { "name": "ada" } ] } }); + let out = + plain(&project_detail(&q, "ABC", Some(&record), &HashMap::new(), None, 100, &palette())); + assert!(!out.contains("1+"), "{}", out); + } + + #[test] + fn a_cycle_count_that_stopped_counting_does_not_call_itself_a_total() { + assert_eq!(running_label(4, false), "4 running"); + assert_eq!(running_label(600, true), "at least 600 running"); + } + + #[test] + fn every_walked_connection_asks_for_a_cursor_and_hands_one_back() { + // pages() sends $after and moves on pageInfo.endCursor. A query + // missing either is walked in place: the same first page fetched + // PAGE_CAP times, or one page returned as the whole connection. + // TEAMS_QUERY asked for hasNextPage, offered no endCursor, and was + // not walked at all - which is how a hundred teams became all of + // them. + let walked: [(&str, String); 6] = [ + ("teams", TEAMS_QUERY.into()), + ("cycles", CYCLES_QUERY.into()), + ("projects", PROJECTS_QUERY.into()), + ("open", open_query()), + ("created", created_query()), + ("done", done_query()), + ]; + for (name, q) in &walked { + assert!(q.contains("$after: String"), "{} declares no cursor:\n{}", name, q); + assert!(q.contains("after: $after"), "{} never passes its cursor:\n{}", name, q); + assert!(q.contains("endCursor"), "{} asks for no endCursor:\n{}", name, q); + } + } + + #[test] + fn milestones_fall_in_date_order_and_their_bars_are_out_of_a_hundred() { + let q = a_project("p1", "hallway-lights", "In Progress", "started", 0.5); + // Linear reports a milestone's progress out of 100 and a project's + // out of 1. Fed straight to the meter, every milestone read full. + let record = serde_json::json!({ + "projectMilestones": { "nodes": [ + { "name": "later", "targetDate": "2026-09-05", "progress": 0.0 }, + { "name": "undated", "targetDate": null, "progress": 50.0 }, + { "name": "sooner", "targetDate": "2026-09-03", "progress": 67.86 }, + ]}, + }); + let out = + plain(&project_detail(&q, "ABC", Some(&record), &HashMap::new(), None, 100, &palette())); + let rows: Vec<&str> = out.lines().collect(); + let seat = |name: &str| rows.iter().position(|l| l.contains(name)).unwrap(); + assert!(seat("sooner") < seat("later"), "{}", out); + assert!(seat("later") < seat("undated"), "dated milestones come first:\n{}", out); + // 67.86 out of a hundred, not 6786%. + let row = rows[seat("sooner")]; + assert!(row.contains(" 68%"), "{}", row); + assert!(row.contains('░'), "a 68% bar is not full: {}", row); + } + + #[test] + fn one_heading_at_a_time_says_tab_and_it_is_the_one_tab_reaches() { + let lens = [5usize, 14, 34]; + let says = |focus: Option<usize>| -> Vec<&'static str> { + (0..3).map(|me| heading_keys(me, focus, &lens)).collect() + }; + // Nothing focused: the first section is where tab goes. + assert_eq!(says(None), [" [tab] to focus", "", ""]); + // Focused: that heading takes the arrows, and the hint moves on to + // the one the next press actually reaches. + assert_eq!(says(Some(0)), [" ↑↓", " [tab] to focus", ""]); + assert_eq!(says(Some(1)), ["", " ↑↓", " [tab] to focus"]); + // From the last, tab lets go - so no heading claims it. + assert_eq!(says(Some(2)), ["", "", " ↑↓"]); + // Never two at once, whatever the focus. + for focus in [None, Some(0), Some(1), Some(2)] { + assert_eq!( + says(focus).iter().filter(|h| h.contains("[tab]")).count() <= 1, + true, + "two headings claimed tab with focus {:?}", + focus + ); + } + // A section with nothing in it never claims it, because tab steps + // over it - a hint on a heading no press reaches is the thing this + // is careful about. + let gap = [5usize, 0, 34]; + assert_eq!(heading_keys(1, Some(0), &gap), ""); + assert_eq!(heading_keys(2, Some(0), &gap), " [tab] to focus"); + } + + #[test] + fn the_board_walks_three_sections_and_lets_go_only_at_the_ends() { + // Cycles, teams, then the board's own project list, in the order + // they are drawn. The rule is the same one every widget here with + // focusable sections follows; this is the third section arriving. + let lens = [5usize, 14, 34]; + assert_eq!(tc::next_section(None, &lens), Some(0)); + assert_eq!(tc::next_section(Some(0), &lens), Some(1)); + assert_eq!(tc::next_section(Some(1), &lens), Some(2)); + assert_eq!(tc::next_section(Some(2), &lens), None); + // Down off the last cycle lands on the first team, and off the + // last team on the first project. + assert_eq!(tc::step_across_sections(0, 4, &lens, true), Some((1, 0))); + assert_eq!(tc::step_across_sections(1, 13, &lens, true), Some((2, 0))); + assert_eq!(tc::step_across_sections(2, 33, &lens, true), None); + // And back up the same way, into the *last* row of the section + // above rather than its first. + assert_eq!(tc::step_across_sections(2, 0, &lens, false), Some((1, 13))); + assert_eq!(tc::step_across_sections(1, 0, &lens, false), Some((0, 4))); + assert_eq!(tc::step_across_sections(0, 0, &lens, false), None); + // A section with nothing in it is stepped over, not landed on. + let gap = [5usize, 0, 34]; + assert_eq!(tc::step_across_sections(0, 4, &gap, true), Some((2, 0))); + assert_eq!(tc::next_section(Some(0), &gap), Some(2)); + } + + #[test] + fn a_project_row_never_overflows_and_never_loses_ground_as_it_widens() { + // Both bugs this function exists for, stated as properties. + let (base, label_w, aside) = (2 + 7 + 2 + 47 + 5, 11usize, 8usize); + let mut had: Option<(bool, bool, bool)> = None; + for w in 40..200usize { + let (label_cost, bar, room) = project_columns(w, base, label_w, aside); + let bar_cost = if bar > 0 { 2 + bar } else { 0 }; + // It fits, once there is room for the name at all. Anything + // wider than the pane is cut by `seg`, and what `seg` cuts is + // the percentage - the row's only number. + if base <= w - 1 { + let aside_cost = if room > 0 { 2 + room } else { 0 }; + assert!( + base + label_cost + bar_cost + aside_cost <= w - 1, + "w={} overflows: base {} label {} bar {} aside {}", + w, base, label_cost, bar_cost, aside_cost + ); + } else { + // Too narrow even for the name: nothing optional is added + // on top of a row that is already over the edge. + assert_eq!((label_cost, bar, room), (0, 0, 0), "w={}", w); + } + // A wider pane never shows less than a narrower one did. + let now = (label_cost > 0, bar > 0, room >= aside); + if let Some(before) = had { + assert!( + now.0 >= before.0 && now.1 >= before.1 && now.2 >= before.2, + "w={} lost a column the narrower pane had: {:?} then {:?}", + w, before, now + ); + } + had = Some(now); + } + // And at the ends: nothing optional survives a very narrow pane, + // everything does in a wide one. + assert_eq!(project_columns(50, base, label_w, aside), (0, 0, 0)); + let (label_cost, bar, room) = project_columns(190, base, label_w, aside); + assert!(label_cost > 0 && bar == 30 && room >= aside); + } + + + #[test] + fn a_team_with_no_projects_says_so_rather_than_showing_an_empty_heading() { + let counts: HashMap<String, usize> = [("open".to_string(), 3usize)].into_iter().collect(); + let out = + team_rows(&counts, &[], &HashMap::new(), 100); + assert!(out.contains("owns no projects"), "{}", out); + } + + #[test] + fn running_projects_sort_above_finished_ones() { + let mut list = vec![ + a_project("p1", "done", "Done", "completed", 1.0), + a_project("p2", "dropped", "Cancelled", "canceled", 0.2), + a_project("p3", "running", "In Progress", "started", 0.3), + a_project("p4", "waiting", "Backlog", "backlog", 0.0), + // A status this build has never heard of belongs with the live + // work, not buried under the finished work. + a_project("p5", "odd", "Shipping", "some_new_type", 0.9), + ]; + list.sort_by(|a, b| rank(&a.kind).cmp(&rank(&b.kind)).then(a.name.cmp(&b.name))); + let order: Vec<&str> = list.iter().map(|q| q.name.as_str()).collect(); + assert_eq!(order, ["running", "odd", "waiting", "done", "dropped"]); + } + + #[test] + fn a_timestamp_that_is_not_ascii_is_declined_rather_than_fatal() { + // A full-width digit puts a character boundary inside byte 19. + // This used to panic in the poll thread, which under the release + // profile takes the whole widget with it. + // Byte 19 falls inside this character; an earlier fixture put the + // wide digit where byte 19 was still a boundary and so passed + // against the bug. Verified by reverting the fix. + assert_eq!(parse("2026-08-24T10:00:0\u{ff14} x"), None); + assert_eq!(parse("\u{4e00}\u{4e00}\u{4e00}\u{4e00}\u{4e00}"), None); + assert_eq!(parse(""), None); + assert_eq!(parse("2026-08-24"), None); + // And a real one still reads. + assert!(parse("2026-08-24T10:00:00.000Z").is_some()); + } + + + #[test] + fn a_span_changes_unit_before_it_stops_meaning_anything() { + assert_eq!(dur(None), "--"); + assert_eq!(dur(Some(0.5)), "30m"); + assert_eq!(dur(Some(3.25)), "3.2h"); + assert_eq!(dur(Some(72.0)), "3.0d"); + // An issue open for 1021.6d is arithmetic; 2.8y is a decision. + assert_eq!(dur(Some(24.0 * 365.0 * 2.8)), "2.8y"); + // Never zero: something that took forty seconds took a minute, not + // no time at all. + assert_eq!(dur(Some(0.001)), "1m"); + } + + #[test] + fn a_median_takes_the_middle_of_an_even_count() { + assert_eq!(median(&[]), None); + assert_eq!(median(&[5.0]), Some(5.0)); + assert_eq!(median(&[1.0, 3.0]), Some(2.0)); + assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0)); + } + + #[test] + fn a_timestamp_gives_up_its_day_and_its_instant() { + assert_eq!(day("2026-08-23T04:15:00.000Z"), "2026-08-23"); + assert_eq!(day(""), ""); + let at = parse("2026-08-23T04:15:00.000Z").expect("a Linear timestamp"); + assert_eq!(at.date().to_string(), "2026-08-23"); + // Anything shorter than a whole instant is not one. + assert!(parse("2026-08-23").is_none()); + assert!(parse("").is_none()); + } + + #[test] + fn a_cycle_is_ranked_by_what_moved_lately() { + let busy: serde_json::Value = serde_json::from_str( + r#"{"scopeHistory": [10,10,10,10,10,20], + "completedScopeHistory": [0,1,2,3,4,5], "endsAt": ""}"#, + ) + .unwrap(); + let quiet: serde_json::Value = serde_json::from_str( + r#"{"scopeHistory": [10,10,10,10,10,10], + "completedScopeHistory": [5,5,5,5,5,5], "endsAt": ""}"#, + ) + .unwrap(); + // Movement is negated so the busiest sorts first. + assert!(churn(&busy).0 < churn(&quiet).0); + // A cycle nothing has touched scores nothing at all, whatever its + // deadline - which is how an empty one sinks without a special case. + assert_eq!(churn(&quiet).0, 0.0); + let empty: serde_json::Value = serde_json::from_str(r#"{"endsAt": ""}"#).unwrap(); + assert_eq!(churn(&empty).0, 0.0); + } + + #[test] + fn a_whole_number_of_points_loses_its_decimal() { + assert_eq!(tidy(8.0), "8"); + assert_eq!(tidy(8.5), "8.5"); + assert_eq!(tidy(0.0), "0"); + } + + #[test] + fn a_key_decides_which_teams_are_counted() { + // Named teams win outright; otherwise the excluded ones are dropped + // and everything else is in. + let of = |v: &[&str]| -> Vec<String> { v.iter().map(|s| s.to_string()).collect() }; + assert!(team_wanted(&of(&["TOY"]), &of(&["OPS"]), "TOY")); + assert!(!team_wanted(&of(&["TOY"]), &of(&[]), "OPS")); + assert!(team_wanted(&of(&[]), &of(&["OPS"]), "TOY")); + assert!(!team_wanted(&of(&[]), &of(&["OPS"]), "OPS")); + // A named team wins even when it is also excluded, which is the + // branch the test-local copy could never have got wrong. + assert!(team_wanted(&of(&["OPS"]), &of(&["OPS"]), "OPS")); + } +} diff --git a/widgets/src/bin/linear_help.txt b/widgets/src/bin/linear_help.txt new file mode 100644 index 0000000..e31784b --- /dev/null +++ b/widgets/src/bin/linear_help.txt @@ -0,0 +1,21 @@ +Linear delivery metrics across every team in the workspace. + +What is outstanding, what the running cycles look like, and whether issues are +being closed faster than they arrive. + + linear [-n SECONDS] [team-key ...] + +Team keys are the prefixes on issue identifiers - XFY, SYS and so on. With +none given every team is included, minus anything in `linear.exclude_teams`. + +Triage is counted apart from the backlog throughout. An auto-filed intake +queue and a groomed backlog are different populations, and adding them +together produces a number that means nothing. + +Credentials: `linear.token` in config.json, or $LINEAR_API_KEY. A personal API +key from Settings - Security & access - Personal API keys. The API is called +directly, so no CLI is required. + +Keys: tab focuses a pane — the cycles, then the teams, then no focus at all — +and up/down move the cursor in whichever is focused, leaving it if you walk off +either end. r refreshes now, w cycles the window (7/14/30/60/90 days), q quits. diff --git a/widgets/src/bin/link.rs b/widgets/src/bin/link.rs new file mode 100644 index 0000000..b6db368 --- /dev/null +++ b/widgets/src/bin/link.rs @@ -0,0 +1,1831 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! How good the connection is between here and whoever is connected to it. +//! +//! A port of link.py. Every other network widget in the collection measures +//! a path it chose; this one measures the path you are on, and it sends +//! nothing to do it - `ss -tin` reports what the kernel has already +//! measured for each established socket. + +use std::collections::HashMap; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +use toys_core as tc; + +const IDLE_AFTER: f64 = 300.0; + +/// The fewest rows the detail chart is worth drawing in. +/// +/// It used to take whatever the fields left over and be dropped silently +/// when that came to less than five, so a short pane showed the numbers and +/// no chart and said nothing about why - indistinguishable from a session +/// with no history. It now takes its rows regardless and the screen scrolls. +const MIN_CHART: usize = 12; + +/// How the detail screen reports where you are in it, at a width that does +/// not change with the numbers - the footer is measured before the body is +/// built, so an indicator that grew by a character could change how many +/// lines the hints wrap onto and leave the body sized for the wrong one. +fn scroll_label(first: usize, last: usize, total: usize) -> String { + format!("rows {:>3}-{:>3} of {:>3}", first, last, total) +} +/// The hues sessions are drawn in, kept as numbers so a faded set can be +/// mixed from the same six. +const HUES: &[(u8, u8, u8)] = &[ + (120, 200, 255), + (150, 230, 180), + (220, 170, 255), + (160, 190, 240), + (200, 220, 150), + (240, 180, 210), +]; + +/// The dark these widgets are drawn against - not the terminal's real +/// background, which cannot be asked for, but the one the palette was chosen +/// for, and what a trace is mixed toward when it is not the one selected. +const BACKDROP: (u8, u8, u8) = (16, 22, 30); + +/// How far an unlooked-at trace is mixed toward the backdrop. Measured +/// against the two things it sits between: at 0.60 a faded trace stays +/// visibly ink rather than sinking into the axis furniture, and is far +/// enough from its own full-strength hue to read as a different weight. +const FADE: f64 = 0.60; + +#[derive(Clone, Default)] +struct Session { + peer: String, + ip: String, + port: u16, + rtt: Option<f64>, + jitter: Option<f64>, + floor: Option<f64>, + sent: f64, + recv: f64, + retrans_bytes: f64, + /// Retransmits since the previous poll, as a percentage of what was + /// sent in that gap. Filled in by the poller, which is the only place + /// that has the previous reading to subtract. + recent_loss: Option<f64>, + delivery: Option<f64>, + mss: Option<f64>, + lastsnd: Option<f64>, + lastrcv: Option<f64>, + raw: HashMap<String, String>, +} + +/// Seconds before an external command is given up on, as link.py gave it. +/// +/// One polling thread feeds this whole widget, and `.output()` waits for +/// ever: a wedged `ss` used to hold that thread open with the pane still +/// drawing its last frame, which is the failure a frozen pane is worst at +/// showing. Bounded, the poll comes back with "ss did not answer in 5s" on +/// the error line and tries again at the next interval. +const RUN_TIMEOUT: u64 = 5; + +/// A command's output, or why it could not be had. +/// +/// `run` folds every failure into an empty string, which is right for the +/// callers that read absence as "nothing to show". It is wrong for the two +/// that feed the whole widget: from an empty string, `ss` failing and `ss` +/// reporting no sockets are the same thing, and one of them is a quiet +/// machine while the other is a broken pane imitating one. +fn run_or(args: &[&str]) -> Result<String, String> { + tc::run(args, RUN_TIMEOUT) +} + +fn run(args: &[&str]) -> String { + tc::run(args, RUN_TIMEOUT).unwrap_or_default() +} + +/// Ports this machine accepts connections on. +/// +/// Inbound is defined as "arrived at a port we listen on" rather than by a +/// list of numbers, so SSH, a terminal server and anything else that +/// accepts sessions are all found without being named. +/// Ports named in config, to be treated as inbound whether or not `ss` +/// reports them listening right now. Set once at startup. +static CONFIGURED_PORTS: std::sync::OnceLock<Vec<u16>> = std::sync::OnceLock::new(); + +/// The `ports` key, read as port numbers. +/// +/// A checked conversion, not `as`: a port is sixteen bits and JSON numbers +/// are not, so `70000 as u16` wraps to 4464 and the widget would go on to +/// treat sessions on 4464 as inbound while ignoring the port the operator +/// asked for - a wrong answer wearing the shape of a right one. An entry +/// that is not a port is dropped instead, which leaves the setting doing +/// nothing rather than doing something else. +fn configured_ports(cfg: &serde_json::Value) -> Vec<u16> { + cfg.get("ports") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_u64()) + .filter_map(|n| u16::try_from(n).ok()) + .collect() + }) + .unwrap_or_default() +} + +fn listening_ports() -> Result<Vec<u16>, String> { + // Seeded from config, as link.py does, then whatever is actually + // listening. The key exists for the ports that are not visibly + // listening at the moment you look. + let mut ports: Vec<u16> = CONFIGURED_PORTS.get().cloned().unwrap_or_default(); + for line in run_or(&["ss", "-tlnH"])?.lines() { + let cols: Vec<&str> = line.split_whitespace().collect(); + if let Some(local) = cols.get(3) { + if let Some((_, port)) = local.rsplit_once(':') { + if let Ok(p) = port.parse() { + ports.push(p); + } + } + } + } + Ok(ports) +} + +/// The kernel's own numbers for one socket. +/// +/// `ss` mixes two shapes on that line: `key:value` pairs and +/// space-separated ones like `delivery_rate 45107960bps`. Both are read; +/// anything unknown is left alone rather than guessed at. +fn parse_metrics(text: &str) -> HashMap<String, String> { + let mut out = HashMap::new(); + let words: Vec<&str> = text.split_whitespace().collect(); + for key in ["send", "pacing_rate", "delivery_rate"] { + if let Some(at) = words.iter().position(|w| *w == key) { + if let Some(value) = words.get(at + 1) { + if let Some(bps) = value.strip_suffix("bps") { + out.insert(key.to_string(), bps.to_string()); + } + } + } + } + for word in &words { + if let Some((key, value)) = word.split_once(':') { + out.insert(key.to_string(), value.to_string()); + } + } + out +} + +fn num(map: &HashMap<String, String>, key: &str) -> Option<f64> { + map.get(key).and_then(|v| v.parse().ok()) +} + +/// One entry per established inbound connection, with its metrics. +fn sessions() -> Result<Vec<Session>, String> { + let ports = listening_ports()?; + if ports.is_empty() { + return Ok(Vec::new()); + } + let text = run_or(&["ss", "-tinH", "state", "established"])?; + let mut found = Vec::new(); + let mut head: Option<Vec<String>> = None; + for line in text.lines() { + if !line.starts_with('\t') && !line.starts_with(' ') { + head = Some(line.split_whitespace().map(|s| s.to_string()).collect()); + continue; + } + let cols = match &head { + Some(c) if c.len() >= 4 => c.clone(), + _ => continue, + }; + let (local, peer) = (&cols[2], &cols[3]); + let lport: u16 = match local.rsplit_once(':').and_then(|(_, p)| p.parse().ok()) { + Some(p) => p, + None => { + head = None; + continue; + } + }; + let (peer_host, peer_port) = match peer.rsplit_once(':') { + Some((h, p)) => (h.trim_matches(|c| c == '[' || c == ']'), p), + None => { + head = None; + continue; + } + }; + // ::ffff:10.0.0.1 is an IPv4 address wearing an IPv6 hat - the same + // machine, the same session - so it is unwrapped before anything + // else looks at it. Left wrapped, ::ffff:127.0.0.1 walked straight + // past the loopback filter and put a 22-microsecond local socket on + // the chart, flattening every real session against the ceiling. + let peer_ip = peer_host.strip_prefix("::ffff:").unwrap_or(peer_host); + if !ports.contains(&lport) || peer_ip.starts_with("127.") || peer_ip.starts_with("::1") { + head = None; + continue; + } + let m = parse_metrics(line); + let rtt_pair = m.get("rtt").cloned().unwrap_or_default(); + let mut halves = rtt_pair.split('/'); + found.push(Session { + peer: format!("{}:{}", peer_ip, peer_port), + ip: peer_ip.to_string(), + port: lport, + rtt: halves.next().and_then(|v| v.parse().ok()), + jitter: halves.next().and_then(|v| v.parse().ok()), + floor: num(&m, "minrtt"), + sent: num(&m, "bytes_sent").unwrap_or(0.0), + recv: num(&m, "bytes_received").unwrap_or(0.0), + retrans_bytes: num(&m, "bytes_retrans").unwrap_or(0.0), + recent_loss: None, + delivery: num(&m, "delivery_rate"), + mss: num(&m, "mss"), + lastsnd: num(&m, "lastsnd"), + lastrcv: num(&m, "lastrcv"), + raw: m, + }); + head = None; + } + Ok(found) +} + +/// Who is logged in from where, to put a name against an address. +/// +/// Login and tty, not just the login: two SSH sessions from one laptop share +/// an address, and the detail view names the ttys to say so. Repeats are +/// kept for the same reason - deduplicating by user threw away the second +/// session, which is the fact that line exists to report. +fn who() -> HashMap<String, Vec<(String, String)>> { + let mut seen: HashMap<String, Vec<(String, String)>> = HashMap::new(); + for line in run(&["who"]).lines() { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 2 { + continue; + } + // The address is the last field, in parentheses, where there is one. + let last = cols[cols.len() - 1]; + if !last.starts_with('(') { + continue; + } + let host = last.trim_matches(|c| c == '(' || c == ')'); + if !host.is_empty() { + seen.entry(host.to_string()) + .or_default() + .push((cols[0].to_string(), cols[1].to_string())); + } + } + seen +} + +fn rate(n: Option<f64>) -> String { + let v = match n { + Some(v) => v, + None => return "--".into(), + }; + for (suffix, scale) in [("Gbps", 1e9), ("Mbps", 1e6), ("kbps", 1e3)] { + if v >= scale { + return format!("{:.1}{}", v / scale, suffix); + } + } + format!("{}bps", v as i64) +} + +/// A byte count as a person would say it. +fn size_of(n: f64) -> String { + for (unit, step) in [("G", 1e9), ("M", 1e6), ("k", 1e3)] { + if n >= step { + return format!("{:.1}{}", n / step, unit); + } + } + format!("{}B", n as i64) +} + +/// Milliseconds on link.py's own scale, which drops to microseconds below +/// one: a loopback socket reads 22us, and 0.02ms hides what that means. +fn ms(value: Option<f64>) -> String { + match value { + None => "--".into(), + Some(v) if v >= 100.0 => format!("{}ms", v.round() as i64), + Some(v) if v >= 10.0 => format!("{:.0}ms", v), + Some(v) if v >= 1.0 => format!("{:.1}ms", v), + Some(v) => format!("{}µs", (v * 1000.0).round() as i64), + } +} + +/// A duration in milliseconds, as a person would say it. +fn span(milliseconds: Option<f64>) -> String { + let s = match milliseconds { + Some(v) => v / 1000.0, + None => return "--".into(), + }; + if s < 60.0 { + format!("{}s", s as i64) + } else if s < 3600.0 { + format!("{}m", (s / 60.0) as i64) + } else if s < 86400.0 { + format!("{}h", (s / 3600.0) as i64) + } else { + format!("{}d", (s / 86400.0) as i64) + } +} + +/// How much worse than this path's best the connection is right now. +/// +/// Compared against the socket's own minrtt rather than a fixed threshold: +/// forty milliseconds is excellent from Hong Kong and poor from the next +/// rack, and the kernel already knows which this is. +fn quality(row: &Session) -> Option<f64> { + match (row.rtt, row.floor) { + (Some(rtt), Some(floor)) if rtt != 0.0 && floor != 0.0 => Some(rtt / floor), + _ => None, + } +} + +fn colour_for<'a>(ratio: Option<f64>, loss: Option<f64>, p: &'a Palette) -> &'a str { + if loss.is_some_and(|l| l >= 2.0) { + return &p.bad; + } + let ratio = match ratio { + Some(r) => r, + None => return &p.dim, + }; + if ratio >= 3.0 || loss.unwrap_or(0.0) >= 0.5 { + &p.bad + } else if ratio >= 1.6 { + &p.warn + } else { + &p.ok + } +} + + +/// Fit samples to the columns available, by median. +/// +/// A fifteen-minute window at a two-second poll is 450 readings and a pane +/// is eighty columns wide, so something has to give. The median of each +/// bucket is the typical round-trip over that slice. +fn condense(values: &[f64], columns: usize) -> Vec<f64> { + if values.len() <= columns || columns < 1 { + return values.to_vec(); + } + let mut out = Vec::with_capacity(columns); + for i in 0..columns { + let from = i * values.len() / columns; + let to = (i + 1) * values.len() / columns; + let mut chunk: Vec<f64> = values[from..to].to_vec(); + if chunk.is_empty() { + continue; + } + chunk.sort_by(|a, b| a.partial_cmp(b).unwrap()); + out.push(chunk[chunk.len() / 2]); + } + out +} + +fn window_label(seconds: f64) -> String { + if seconds < 60.0 { + format!("{}s", seconds as i64) + } else if seconds < 3600.0 { + format!("{}m", (seconds / 60.0).round() as i64) + } else { + format!("{}h", (seconds / 3600.0).round() as i64) + } +} + +struct State { + rows: Vec<Session>, + names: HashMap<String, Vec<(String, String)>>, + history: HashMap<String, Vec<f64>>, + err: String, +} + +fn main() { + tc::maybe_help(include_str!("link_help.txt")); + let cfg = tc::load_config("link"); + let named = configured_ports(&cfg); + if !named.is_empty() { + let _ = CONFIGURED_PORTS.set(named); + } + let refresh = tc::poll_secs(tc::cfg_f64(&cfg, "refresh", 2.0), 2.0).max(0.5); + let windows: Vec<f64> = { + let got = cfg + .get("windows") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_f64()) + .filter(|v| v.is_finite() && *v > 0.0) + .collect::<Vec<f64>>() + }) + .unwrap_or_default(); + if got.is_empty() { + vec![60.0, 300.0, 900.0, 3600.0] + } else { + got + } + }; + // Retention has to cover the longest span on offer, or w would cycle to + // a window the samples could never fill. + let history_len = ((windows.iter().cloned().fold(0.0f64, f64::max) / refresh) as usize + 2) + .max(tc::cfg_usize(&cfg, "history", 120)); + + let absent = tc::missing(&["ss"]); + if !absent.is_empty() { + hold(&absent); + return; + } + + let p = palette(); + let state = Arc::new(Mutex::new(State { + rows: Vec::new(), + names: HashMap::new(), + history: HashMap::new(), + err: String::new(), + })); + // [r] asks for a reading now rather than at the end of the interval, so + // the poller sleeps on a condition it can be woken out of. + let wake = Arc::new((Mutex::new(false), Condvar::new())); + let poller = Arc::clone(&state); + let poller_wake = Arc::clone(&wake); + std::thread::spawn(move || { + // A thread that ends leaves the last frame on screen for ever, and a + // widget frozen on real numbers is harder to catch than one showing + // none. Every way out of this loop writes down why first - which is + // what link.py's `run` does around `poll`, and what this port had + // lost: `State.err` was drawn but never assigned, so the line that + // explains a dead poller could not fire. + let stopped = |why: &str| { + if let Ok(mut guard) = poller.lock() { + guard.err = format!("poller stopped: {}", why); + } + }; + let mut last: HashMap<String, (f64, f64)> = HashMap::new(); + loop { + let mut found = match sessions() { + Ok(rows) => { + if let Ok(mut guard) = poller.lock() { + guard.err = String::new(); + } + rows + } + // Not fatal: ss can fail for a moment. Reported and retried + // rather than ending the thread, but never silently - an empty + // list and a failed read look identical on screen otherwise. + Err(why) => { + if let Ok(mut guard) = poller.lock() { + guard.err = why; + } + Vec::new() + } + }; + let names = who(); + for row in &mut found { + // Retransmits since the last look, rather than since the + // connection opened: a session hours old has long since + // forgiven whatever went wrong at breakfast. + if let Some((sent, retrans)) = last.get(&row.peer) { + let moved = row.sent - sent; + row.recent_loss = Some(if moved > 0.0 { + 100.0 * (row.retrans_bytes - retrans) / moved + } else { + 0.0 + }); + } + last.insert(row.peer.clone(), (row.sent, row.retrans_bytes)); + } + { + let mut guard = match poller.lock() { + Ok(g) => g, + Err(_) => return, + }; + for row in &found { + if let Some(rtt) = row.rtt { + let series = guard.history.entry(row.peer.clone()).or_default(); + series.push(rtt); + if series.len() > history_len { + let drop = series.len() - history_len; + series.drain(..drop); + } + } + } + guard.rows = found; + guard.names = names; + } + let (lock, cond) = &*poller_wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return stopped("the wake lock was poisoned"), + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(refresh)) { + Ok((g, _)) => g, + Err(_) => return stopped("the wake lock was poisoned while waiting"), + }; + } + *asked = false; + } + }); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + // Nothing selected until asked for, and no selection is the position + // above the first row and below the last, so walking off either end + // lands there and walking off it again comes in at the other. The chart + // then opens showing every session at equal weight, and focus is + // something you leave the way you entered it. + let (mut selected, mut hide_idle, mut span_at) = + (None::<usize>, false, 0usize); + let mut count = 0usize; + let mut detail = false; + // How far down the detail screen we are. Clamped against the body every + // frame rather than when the key is pressed, because the body's length + // depends on the pane, which can change under us between frames. + let mut scroll = 0usize; + + loop { + // Read before the keys rather than after them, so a page key knows + // how big a page is on this pane. + let (w, h) = tc::size(); + let page = h.saturating_sub(4).max(1); + for key in keyboard.poll() { + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + // Up and down mean "move through what is in front of you" + // in both views: in the list that is the selection, on the + // detail screen it is the screen itself. + "up" | "k" | "K" => { + if detail { + scroll = scroll.saturating_sub(1); + } else { + selected = match selected { + None => count.checked_sub(1), + Some(0) => None, + Some(at) => Some(at - 1), + }; + } + } + "down" | "j" | "J" => { + if detail { + scroll = scroll.saturating_add(1); + } else { + selected = match selected { + None if count > 0 => Some(0), + None => None, + Some(at) if at + 1 >= count => None, + Some(at) => Some(at + 1), + }; + } + } + // Right goes in and left comes back out, the way a column + // of panes works, so the hand does not have to learn a key + // for it. Enter and esc still do the same two things. + "right" | "enter" if !detail => { + // Opening with nothing selected takes the first row + // rather than doing nothing, which would be a key that + // the footer offers and that does not answer. + if selected.is_none() && count > 0 { + selected = Some(0); + } + detail = selected.is_some(); + scroll = 0; + } + "left" | "esc" if detail => { + detail = false; + scroll = 0; + } + // Stepping between connections without going back to the + // list. This was on left and right until those were given + // their directional meaning; it is worth keeping, because + // comparing two sockets is most of what the detail screen + // is for. + // Stays within the list rather than falling off it: the + // detail screen has to be showing something. + "n" | "N" if detail => { + selected = selected.map(|at| (at + 1).min(count.saturating_sub(1))); + scroll = 0; + } + "p" | "P" if detail => { + selected = selected.map(|at| at.saturating_sub(1)); + scroll = 0; + } + "pgup" if detail => scroll = scroll.saturating_sub(page), + "pgdn" if detail => scroll = scroll.saturating_add(page), + "home" if detail => scroll = 0, + // Clamped to the end of the body when the frame is drawn. + "end" if detail => scroll = usize::MAX, + "o" | "O" => hide_idle = !hide_idle, + "w" | "W" => span_at = (span_at + 1) % windows.len(), + "r" | "R" => { + let (lock, cond) = &*wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + } + _ => {} + } + } + + let guard = match state.lock() { + Ok(g) => g, + Err(_) => return, + }; + let shown: Vec<Session> = guard + .rows + .iter() + .filter(|r| !(hide_idle && r.lastrcv.unwrap_or(0.0) > IDLE_AFTER * 1000.0)) + .cloned() + .collect(); + count = shown.len(); + if let Some(at) = selected { + selected = if shown.is_empty() { + None + } else { + Some(at.min(shown.len() - 1)) + }; + } + if selected.is_none() { + detail = false; + } + let window = windows[span_at]; + + // One connection in full, on its own screen. The list is for + // noticing; this is for looking into, and the two want different + // amounts of room for the same chart. + if let (true, Some(pick)) = (detail && !shown.is_empty(), selected) { + // The footer is measured before the body is built, and the body + // is told the height it actually has. Sizing the chart to the + // whole pane and appending the hints afterwards pushed them off + // the bottom of it - the keys out of this screen were the rows + // being lost. + // Built twice: once to learn how many lines the hints wrap + // onto, and again with the real position once the body exists. + // `scroll_label` is a fixed width, so the second pass cannot + // wrap differently from the first and leave the body sized + // against a footer that is no longer there. + let detail_hints = |place: String| -> Vec<Vec<(&str, String)>> { + vec![ + vec![ + (p.accent.as_str(), "←".into()), + (p.dim.as_str(), "/[esc] back".into()), + ], + vec![ + (p.accent.as_str(), "↑↓".into()), + (p.dim.as_str(), " scroll".into()), + ], + vec![(p.dim.as_str(), "[n]ext [p]rev".into())], + vec![ + (p.accent.as_str(), "[w]".into()), + (p.dim.as_str(), format!(" {}", window_label(window))), + ], + vec![(p.dim.as_str(), "[r]efresh".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + vec![(p.dim.as_str(), place)], + ] + }; + let pack = |hints: &[Vec<(&str, String)>]| -> Vec<String> { + tc::pack_hints(hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect() + }; + let foot = pack(&detail_hints(scroll_label(0, 0, 0))); + let room = h.saturating_sub(foot.len() + 1).max(1); + let body = detail_view(&shown[pick], &guard, w, room, pick, window, refresh, &p); + drop(guard); + // The body is as tall as it needs to be and the pane shows a + // window onto it, rather than the body being cut to the pane + // and the remainder going unmentioned. + let furthest = body.len().saturating_sub(room); + scroll = scroll.min(furthest); + let last = (scroll + room).min(body.len()); + let mut shown_body: Vec<String> = body[scroll..last].to_vec(); + while shown_body.len() < room { + shown_body.push(String::new()); + } + shown_body.extend(pack(&detail_hints(scroll_label( + scroll + 1, + last, + body.len(), + )))); + tc::draw(&shown_body, w, h); + std::thread::sleep(Duration::from_millis(200)); + continue; + } + + let mut rows = vec![tc::title("connections", w, &p.link)]; + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} inbound", guard.rows.len())), + ( + p.dim.as_str(), + " · measured by the kernel, nothing sent".into(), + ), + (p.dim.as_str(), format!(" every {}s", refresh)), + ], + w - 1, + )); + if !guard.err.is_empty() { + rows.push(tc::seg(&[(p.bad.as_str(), format!(" ! {}", guard.err))], w - 1)); + } + rows.push(String::new()); + + if guard.rows.is_empty() { + rows.push(tc::seg( + &[( + p.dim.as_str(), + " No inbound sessions on a listening port.".into(), + )], + w - 1, + )); + rows.push(tc::seg( + &[( + p.dim.as_str(), + " Nothing is connected to this machine, or ss cannot see it.".into(), + )], + w - 1, + )); + } else { + rows.extend(table(&shown, &guard, w, selected, &p)); + rows.push(String::new()); + let room = h.saturating_sub(rows.len() + 4); + if room < 5 { + // Say so rather than leaving a gap: a chart that is missing + // for want of rows looks exactly like one missing for want + // of data, and only one of those is the reader's to fix. + if room >= 1 { + rows.push(tc::seg( + &[( + p.dim.as_str(), + format!(" chart needs {} more rows", 5 - room), + )], + w - 1, + )); + } + } else { + rows.extend(graph( + &shown, + &guard.history, + w, + room, + 0, + selected, + window, + refresh, + &p, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " ".repeat(7)), + (p.grid.as_str(), format!("└{}", "─".repeat(w.saturating_sub(9).max(10)))), + ], + w - 1, + )); + let covered = plotted_span(&shown, &guard.history, window, refresh, w); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ago", span(Some(covered * 1000.0)))), + (p.dim.as_str(), " ".repeat(w.saturating_sub(26).max(1))), + (p.dim.as_str(), "now".into()), + ], + w - 1, + )); + } + } + + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " select".into())], + vec![ + (p.accent.as_str(), "→".into()), + (p.dim.as_str(), "/[↵] open".into()), + ], + vec![ + (p.accent.as_str(), "[w]".into()), + (p.dim.as_str(), format!(" {}", window_label(window))), + ], + vec![( + p.dim.as_str(), + format!("[o]{} idle", if hide_idle { "show" } else { "hide" }), + )], + vec![(p.dim.as_str(), "[r]efresh".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + drop(guard); + let foot: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + while rows.len() < h.saturating_sub(foot.len()) { + rows.push(String::new()); + } + rows.extend(foot); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(300)); + } +} + +fn plotted_span( + rows: &[Session], + history: &HashMap<String, Vec<f64>>, + window: f64, + refresh: f64, + w: usize, +) -> f64 { + let _ = w; + let longest = rows + .iter() + .filter_map(|r| history.get(&r.peer).map(|h| h.len())) + .max() + .unwrap_or(0); + let capped = longest.min((window / refresh).round() as usize); + capped as f64 * refresh +} + +fn table( + rows: &[Session], + state: &State, + w: usize, + selected: Option<usize>, + p: &Palette, +) -> Vec<String> { + // The Python's header, column for column: the two have to sit side by + // side in a wall and read as the same widget. + let wide = w >= 74; + // Two for the glyph and its space, eighteen for the name: together they + // land the NOW column under its heading. Three and twenty also add up to + // a plausible-looking row, and put every number three cells right of the + // word above it. + // Wide enough for the peer's own port, because that is the only field + // that differs between sockets from one machine to one service: four + // browser tabs against the same dev server share an address and a local + // port and are told apart by nothing else. 21 fits the longest IPv4 + // address and port; the wider pane spends five more on the login. + let name_w = if wide { 26usize } else { 21usize }; + let mut out = vec![tc::seg( + &[ + (p.dim.as_str(), " PEER".into()), + (p.dim.as_str(), " ".repeat(name_w - 4)), + (p.dim.as_str(), " NOW FLOOR JITTER LOSS".into()), + (p.dim.as_str(), if wide { " ACHIEVED".into() } else { String::new() }), + (p.dim.as_str(), if wide { " IDLE".into() } else { String::new() }), + ], + w - 1, + )]; + for (i, row) in rows.iter().enumerate() { + let here = selected == Some(i); + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let hue = &p.hues[i % p.hues.len()]; + + // One login, and only where there is room for it: the address is + // what identifies the session, the name is a courtesy. + let who = state + .names + .get(&row.ip) + .and_then(|names| names.first()) + .map(|(user, _tty)| user.clone()) + .unwrap_or_default(); + let label = if who.is_empty() || !wide { + row.peer.clone() + } else { + format!("{} {}", row.peer, who) + }; + let loss = row.recent_loss; + let tone = format!("{}{}", tint, colour_for(quality(row), loss, p)); + let name_c = format!("{}{}", tint, hue); + let label_c = format!("{}{}", tint, if here { &p.txt } else { &p.dim }); + let dim_c = format!("{}{}", tint, p.dim); + let loss_c = format!( + "{}{}", + tint, + if loss.unwrap_or(0.0) >= 0.5 { &p.bad } else { &p.dim } + ); + let idle = [row.lastsnd, row.lastrcv] + .into_iter() + .flatten() + .fold(None, |acc: Option<f64>, v| Some(acc.map_or(v, |a| a.min(v)))); + // A missing reading is a dash of the column's own width, not a + // narrower cell - anything else shifts every number to its right. + let cell = |value: Option<f64>, width: usize| match value { + Some(v) if v != 0.0 => format!("{:>width$}", ms(Some(v)), width = width), + _ => format!("{:>width$}", "--", width = width), + }; + let mut line = vec![ + // A colour chip rather than a shape. Six shapes told six + // sessions apart no better than six hues did, and a seventh + // session repeated the first one's shape - so the chip carries + // the hue and the name carries the selection. + (name_c.as_str(), "▐".to_string()), + (label_c.as_str(), " ".to_string()), + (label_c.as_str(), tc::pad(&label, name_w)), + (tone.as_str(), cell(row.rtt, 7)), + (dim_c.as_str(), cell(row.floor, 8)), + (dim_c.as_str(), cell(row.jitter, 8)), + ( + loss_c.as_str(), + format!( + "{:>7}", + loss.map_or("--".to_string(), |v| format!("{:.2}%", v)) + ), + ), + ]; + if wide { + line.push((dim_c.as_str(), format!("{:>10}", rate(row.delivery)))); + line.push((dim_c.as_str(), format!("{:>7}", span(idle)))); + } + if here { + line.push((tint.as_str(), " ".repeat(w))); + } + out.push(tc::seg(&line, w - 1)); + } + out +} + +/// Log-scale multi-series plot of round-trip time. +/// One connection, in full. +/// +/// The list answers "is anything wrong"; this answers "with what, and how +/// badly". Everything here is a number the kernel already keeps for this +/// socket - nothing is derived beyond the two percentages, and both say +/// what they are measured over. +#[allow(clippy::too_many_arguments)] +fn detail_view( + row: &Session, + state: &State, + w: usize, + h: usize, + idx: usize, + window: f64, + refresh: f64, + p: &Palette, +) -> Vec<String> { + let empty = Vec::new(); + let users = state.names.get(&row.ip).unwrap_or(&empty); + let mut rows = vec![tc::title("connection", w, &p.link)]; + rows.push(tc::seg( + &[ + (p.hues[idx % p.hues.len()].as_str(), " ▐ ".to_string()), + (p.txt.as_str(), row.peer.clone()), + // Their port identifies the socket; ours identifies the service + // it reached. Both, because the list is keyed on the first and + // the question "what is this connected to" is answered by the + // second. + (p.dim.as_str(), format!(" · to port {}", row.port)), + ( + p.dim.as_str(), + users.first().map_or(String::new(), |(u, _)| format!(" {}", u)), + ), + ], + w - 1, + )); + // `who` maps logins to an address, not to a socket, and two SSH sessions + // from one laptop share the address. Naming both against each socket + // read as "this connection is pts/0 and pts/35", which it is not - so + // the ttys are labelled as what they are: the logins from that address. + if !users.is_empty() { + rows.push(tc::seg( + &[ + (p.dim.as_str(), " logins from this address: ".into()), + ( + p.txt.as_str(), + users + .iter() + .map(|(_u, tty)| tty.clone()) + .collect::<Vec<_>>() + .join(", "), + ), + ], + w - 1, + )); + } + rows.push(String::new()); + + // A field with nothing behind it is left out rather than shown empty: + // the screen is a list of what the kernel knows, so a missing line says + // "not reported for this socket". + macro_rules! field { + ($label:expr, $value:expr, $colour:expr, $note:expr $(,)?) => {{ + if let Some(v) = $value { + if !v.is_empty() && v != "--" { + let note: &str = $note; + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {:<16}", $label)), + ($colour, v), + ( + p.dim.as_str(), + if note.is_empty() { + String::new() + } else { + format!(" {}", note) + }, + ), + ], + w - 1, + )); + } + } + }}; + } + + let ratio = quality(row); + let live = |v: Option<f64>| v.filter(|x| *x != 0.0).map(|x| ms(Some(x))); + field!( + "round trip", + live(row.rtt), + colour_for(ratio, row.recent_loss, p), + &ratio.map_or(String::new(), |r| format!("{:.1}x this path's best", r)), + ); + field!( + "best ever", + live(row.floor), + &p.dim, + "the floor; the gap above it is congestion", + ); + field!("jitter", live(row.jitter), &p.dim, "variation in the round trip"); + field!( + "timeout", + num(&row.raw, "rto").map(|v| ms(Some(v))), + &p.dim, + "how long before a lost packet is resent", + ); + rows.push(String::new()); + + // Lifetime loss lives here rather than in the table because it is a fact + // about the whole session and changes by the hour, while the table's + // loss column is about the last two seconds. + let lifetime = if row.sent > 0.0 { + 100.0 * row.retrans_bytes / row.sent + } else { + 0.0 + }; + let loss = row.recent_loss; + field!( + "loss just now", + loss.map(|v| format!("{:.2}%", v)), + if loss.unwrap_or(0.0) >= 0.5 { &p.bad } else { &p.txt }, + "resent since the last look", + ); + field!( + "loss lifetime", + Some(format!("{:.2}%", lifetime)), + &p.dim, + &format!( + "{} resent of {}", + size_of(row.retrans_bytes), + size_of(row.sent) + ), + ); + field!( + "reordering", + row.raw.get("reord_seen").cloned(), + &p.dim, + "times packets arrived out of order", + ); + rows.push(String::new()); + + field!("sent", Some(size_of(row.sent)), &p.txt, ""); + field!("received", Some(size_of(row.recv)), &p.txt, ""); + field!( + "achieved", + Some(rate(row.delivery)), + &p.txt, + "what it has delivered, not its capacity", + ); + field!( + "pacing at", + Some(rate(num(&row.raw, "pacing_rate"))), + &p.dim, + "the rate the kernel is willing to send at", + ); + field!( + "in flight", + row.raw.get("cwnd").cloned(), + &p.dim, + "packets allowed unacknowledged at once", + ); + field!( + "packet size", + row.mss.map(|v| format!("{} bytes", v as i64)), + &p.dim, + "", + ); + let idle = [row.lastsnd, row.lastrcv] + .into_iter() + .flatten() + .fold(None, |acc: Option<f64>, v| Some(acc.map_or(v, |a| a.min(v)))); + field!( + "idle", + Some(span(idle)), + &p.dim, + "since anything crossed either way", + ); + rows.push(String::new()); + + let one = [row.clone()]; + // Not `if room >= 5`: the chart takes MIN_CHART rows even when the + // fields have already spent the pane, and the caller scrolls to reach + // what will not fit. A chart quietly missing reads as missing data. + let room = h.saturating_sub(rows.len() + 4).max(MIN_CHART); + { + rows.extend(graph( + &one, + &state.history, + w, + room, + idx, + // One session on its own screen: there is nothing to push back, + // so it is drawn at full strength like everything else. + None, + window, + refresh, + p, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " ".repeat(7)), + ( + p.grid.as_str(), + format!("└{}", "─".repeat(w.saturating_sub(9).max(10))), + ), + ], + w - 1, + )); + let covered = plotted_span(&one, &state.history, window, refresh, w); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ago", span(Some(covered * 1000.0)))), + (p.dim.as_str(), " ".repeat(w.saturating_sub(26).max(1))), + (p.dim.as_str(), "now".into()), + ], + w - 1, + )); + } + rows +} + +/// A braille cell is two dots wide and four tall, so one character holds +/// eight addressable points. The bit for each is fixed by the encoding. +const BRAILLE: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]; + +/// Plot one session's round trips on a dot canvas finer than the cells. +/// +/// Consecutive samples are joined rather than left as marks, which is the +/// difference between a line that reads as a path moving and one that reads +/// as specks a row apart. The masks come back per cell instead of as text so +/// that several sessions can be laid over one another first. +/// +/// `slots` is how many samples the axis holds, which is not how many this +/// session has: newest sits against the right edge either way, and a session +/// younger than the chart takes its own share of the width rather than being +/// stretched over all of it. The longest session fills the axis by +/// definition, and it is the one the "N ago" under the corner is measured +/// from, so the label and the left edge cannot drift apart. +fn braille_canvas( + values: &[f64], + llo: f64, + lhi: f64, + cols: usize, + rows: usize, + slots: usize, +) -> Vec<Vec<u8>> { + let (px_w, px_h) = (cols * 2, rows * 4); + let mut grid = vec![vec![0u8; cols]; rows]; + if values.is_empty() || px_w == 0 || px_h == 0 { + return grid; + } + let vals: Vec<f64> = values.iter().rev().take(px_w).rev().copied().collect(); + let step = (px_w as f64 - 1.0) / (slots.max(2) as f64 - 1.0); + let decade = (lhi - llo).max(1e-9); + let point = |i: usize| -> (i64, i64) { + let frac = ((vals[i].max(1e-3).log10() - llo) / decade).clamp(0.0, 1.0); + let age = (vals.len() - 1 - i) as f64; + ( + px_w as i64 - 1 - (age * step).round() as i64, + ((1.0 - frac) * (px_h as f64 - 1.0)).round() as i64, + ) + }; + let dot = |x: i64, y: i64, grid: &mut Vec<Vec<u8>>| { + if x >= 0 && (x as usize) < px_w && y >= 0 && (y as usize) < px_h { + grid[y as usize / 4][x as usize / 2] |= BRAILLE[y as usize % 4][x as usize % 2]; + } + }; + // Every value here is a round trip the kernel measured, so unlike + // netwatch's idle zero there is no reading that means "nothing happened" + // and should be left blank. One sample is a measurement and gets its dot. + let (x, y) = point(0); + dot(x, y, &mut grid); + for i in 1..vals.len() { + let (mut x0, mut y0) = point(i - 1); + let (x1, y1) = point(i); + let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs()); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + dot(x0, y0, &mut grid); + if x0 == x1 && y0 == y1 { + break; + } + let twice = 2 * err; + if twice >= dy { + err += dy; + x0 += sx; + } + if twice <= dx { + err += dx; + y0 += sy; + } + } + } + grid +} + +/// Lay the canvases over one another, cell by cell. +/// +/// A braille cell can hold the dots of two traces but only one colour, and +/// there is no honest way to show both: whichever colour the cell takes, the +/// other session's samples are drawn in a hue that is not theirs. This used +/// to merge the dots and give the cell to whichever session came later in +/// the list, and on latency's chart - the same code - that hid a whole host +/// behind another one eleven milliseconds away, drawn end to end in a colour +/// it did not own. +/// +/// So a cell belongs to exactly one trace and shows only that trace's dots. +/// Where several want it, ownership advances once per contested cell, which +/// makes a contested stretch read as interleaved dashed lines - each dot its +/// own colour - rather than one solid line belonging to nobody. Traces that +/// never meet are unaffected and stay solid. +/// +/// The turn is counted over contested cells and not taken from the column +/// number, which is the same bug one level in. Indexing by `x` looks fair +/// and is not: when the contested cells all share a parity - which happens +/// as soon as one series has a gap in every other column, and the column +/// width is free-form config - `x % 2` picks the same claimant every time +/// and the other trace is absent from the whole stretch. That is the +/// failure this function exists to prevent, in a narrower form. +fn overlay(layers: &[(String, Vec<Vec<u8>>)], cols: usize, rows: usize) -> Vec<Vec<(String, u8)>> { + let mut cells = vec![vec![(String::new(), 0u8); cols]; rows]; + for y in 0..rows { + // Counts contested cells along this row, so every claimant takes a + // turn no matter which columns the contention lands on. + let mut turn = 0usize; + for x in 0..cols { + let dots = |canvas: &Vec<Vec<u8>>| { + canvas.get(y).and_then(|line| line.get(x)).copied().unwrap_or(0) + }; + let claims: Vec<&(String, Vec<Vec<u8>>)> = + layers.iter().filter(|(_, canvas)| dots(canvas) != 0).collect(); + let Some((colour, canvas)) = claims.get(turn % claims.len().max(1)) else { + continue; + }; + cells[y][x] = ((*colour).clone(), dots(canvas)); + if claims.len() > 1 { + turn += 1; + } + } + } + cells +} + +#[allow(clippy::too_many_arguments)] +fn graph( + rows: &[Session], + history: &HashMap<String, Vec<f64>>, + w: usize, + h: usize, + // `start` keeps a session's glyph and hue the same on its own screen as + // in the list: opening the ▲ row and finding a ● chart reads as a + // different connection. + start_at: usize, + focus: Option<usize>, + window: f64, + refresh: f64, + p: &Palette, +) -> Vec<String> { + let gw = w.saturating_sub(9).max(10); + let gh = h.max(4); + let want = ((window / refresh).round() as usize).max(1); + let series: Vec<(usize, Vec<f64>)> = rows + .iter() + .enumerate() + .filter_map(|(i, row)| { + let all = history.get(&row.peer)?; + let start = all.len().saturating_sub(want); + // The same span of history as before, condensed to twice as many + // points: two dots to a cell across. + let vals = condense(&all[start..], gw * 2); + if vals.is_empty() { + None + } else { + Some((start_at + i, vals)) + } + }) + .collect(); + if series.is_empty() { + return vec![tc::seg(&[(p.dim.as_str(), " collecting…".into())], w - 1)]; + } + let lo = series + .iter() + .flat_map(|(_, v)| v.iter()) + .cloned() + .fold(f64::INFINITY, f64::min) + .max(0.05) + * 0.8; + let hi = (series + .iter() + .flat_map(|(_, v)| v.iter()) + .cloned() + .fold(0.0f64, f64::max) + * 1.25) + .max(lo * 1.6); + let (llo, lhi) = (lo.log10(), hi.log10()); + + // The axis holds as many samples as the longest session has, which is + // the same number plotted_span turns into the "N ago" beneath the chart: + // one quantity, so the label and the left edge state the same thing. + let slots = series.iter().map(|(_, v)| v.len()).max().unwrap_or(1); + // One canvas per session rather than one shared grid: a braille cell can + // carry the dots of two traces but only one hue, so each series has to + // keep its own until the moment they are laid over one another. + // The selected session is laid down last and at full strength while the + // rest are mixed toward the backdrop, so the trace being looked at wins + // any cell it shares. With nothing selected every trace is equal, which + // is the chart this widget has always drawn. + let mut layers: Vec<(String, Vec<Vec<u8>>)> = Vec::with_capacity(series.len()); + let mut front: Option<(String, Vec<Vec<u8>>)> = None; + for (idx, values) in &series { + let canvas = braille_canvas(values, llo, lhi, gw, gh, slots); + let hue = p.hues[idx % p.hues.len()].clone(); + match focus { + None => layers.push((hue, canvas)), + Some(at) if at == *idx => front = Some((hue, canvas)), + Some(_) => layers.push((p.faded[idx % p.faded.len()].clone(), canvas)), + } + } + let mut cells = overlay(&layers, gw, gh); + // The focused trace takes its cells outright rather than being merged + // into them: a sample drawn in a colour that is not its own is a number + // on screen that is not real. + if let Some((hue, canvas)) = front { + for (y, line) in canvas.iter().enumerate().take(gh) { + for (x, mask) in line.iter().enumerate().take(gw) { + if *mask != 0 { + cells[y][x] = (hue.clone(), *mask); + } + } + } + } + + let mut out = Vec::new(); + for (y, line) in cells.iter().enumerate() { + let frac = 1.0 - (y as f64 / (gh as f64 - 1.0).max(1.0)); + let value = 10f64.powf(llo + frac * (lhi - llo)); + let label = if y == 0 || y == gh / 2 || y == gh - 1 { + format!("{:>7}", ms(Some(value))) + } else { + " ".repeat(7) + }; + let mut parts: Vec<(&str, String)> = + vec![(p.dim.as_str(), label), (p.grid.as_str(), "│".into())]; + for (colour, mask) in line { + parts.push(match mask { + 0 => (p.grid.as_str(), " ".into()), + m => ( + colour.as_str(), + char::from_u32(0x2800 + *m as u32).unwrap_or(' ').to_string(), + ), + }); + } + out.push(tc::seg(&parts, w - 1)); + } + out +} + +/// Draw the reason and wait, rather than exiting. +fn hold(needed: &[String]) { + let bad = tc::rgb(255, 100, 110); + let dim = tc::rgb(127, 147, 172); + let txt = tc::rgb(225, 235, 245); + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + loop { + for key in keyboard.poll() { + if key == "q" || key == "Q" { + keyboard.restore(); + tc::restore_screen(); + return; + } + } + let (w, h) = tc::size(); + let mut rows = vec![tc::title("connections", w, &bad), String::new()]; + rows.push(tc::seg( + &[ + (bad.as_str(), " cannot start · ".into()), + (txt.as_str(), format!("needs {}", needed.join(", "))), + ], + w - 1, + )); + rows.push(String::new()); + for line in [ + "ss reads the kernel's own per-socket metrics, which is where", + "every figure here comes from: round-trip time, retransmits,", + "delivery rate. Nothing else on the machine reports them.", + ] { + rows.push(tc::seg(&[(dim.as_str(), format!(" {}", line))], w - 1)); + } + rows.push(String::new()); + rows.push(tc::seg( + &[ + (dim.as_str(), " try: ".into()), + (txt.as_str(), "apt install iproute2".into()), + ], + w - 1, + )); + while rows.len() < h - 1 { + rows.push(String::new()); + } + rows.push(tc::seg(&[(dim.as_str(), " [q]uit".into())], w - 1)); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(200)); + } +} + +struct Palette { + ok: String, + warn: String, + bad: String, + dim: String, + grid: String, + txt: String, + accent: String, + link: String, + hues: Vec<String>, + /// The same six, mixed toward the backdrop, for traces that are not the + /// one selected. + faded: Vec<String>, +} + +fn palette() -> Palette { + Palette { + ok: tc::rgb(90, 240, 160), + warn: tc::rgb(255, 200, 90), + bad: tc::rgb(255, 100, 110), + dim: tc::rgb(127, 147, 172), + grid: tc::rgb(60, 78, 98), + txt: tc::rgb(225, 235, 245), + accent: tc::rgb(150, 210, 255), + link: tc::rgb(140, 200, 255), + hues: HUES.iter().map(|c| tc::rgb(c.0, c.1, c.2)).collect(), + faded: HUES.iter().map(|c| tc::mix(*c, BACKDROP, FADE)).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_shapes_on_the_ss_line_are_read() { + let line = "\t ts sack cubic rtt:3.604/1.027 minrtt:3.553 cwnd:10 \ + bytes_sent:1669 delivery_rate 6287464bps"; + let m = parse_metrics(line); + assert_eq!(m.get("rtt").map(String::as_str), Some("3.604/1.027")); + assert_eq!(m.get("minrtt").map(String::as_str), Some("3.553")); + // The space-separated shape, which a key:value scan alone misses. + assert_eq!(m.get("delivery_rate").map(String::as_str), Some("6287464")); + assert_eq!(num(&m, "cwnd"), Some(10.0)); + } + + #[test] + fn milliseconds_follow_the_python_scale() { + assert_eq!(ms(Some(123.4)), "123ms"); + assert_eq!(ms(Some(28.1)), "28ms"); + assert_eq!(ms(Some(2.74)), "2.7ms"); + // Below a millisecond it changes unit rather than losing the value. + assert_eq!(ms(Some(0.022)), "22µs"); + assert_eq!(ms(None), "--"); + } + + #[test] + fn rates_read_as_a_person_would_say_them() { + assert_eq!(rate(Some(6_287_464.0)), "6.3Mbps"); + // Lower-case k, as link.py writes it: the two sit side by side on a + // wall and a capital there reads as a different widget. + assert_eq!(rate(Some(1_500.0)), "1.5kbps"); + assert_eq!(rate(Some(45_000_000_000.0)), "45.0Gbps"); + assert_eq!(rate(None), "--"); + // A socket that has delivered nothing has delivered nothing; it is + // not an absent reading. + assert_eq!(rate(Some(0.0)), "0bps"); + } + + #[test] + fn byte_counts_read_as_a_person_would_say_them() { + assert_eq!(size_of(1_669.0), "1.7k"); + assert_eq!(size_of(15_900_000.0), "15.9M"); + assert_eq!(size_of(512.0), "512B"); + } + + #[test] + fn spans_come_from_milliseconds() { + assert_eq!(span(Some(45_000.0)), "45s"); + assert_eq!(span(Some(600_000.0)), "10m"); + // The unit changes at the unit, not half again past it: 90s is a + // minute and a half, and reading it as "90s" was a threshold this + // port had invented. + assert_eq!(span(Some(90_000.0)), "1m"); + assert_eq!(span(Some(7_200_000.0)), "2h"); + assert_eq!(span(None), "--"); + } + + #[test] + fn a_connection_is_judged_against_its_own_best() { + let near = Session { + rtt: Some(1.2), + floor: Some(1.0), + ..Default::default() + }; + let far = Session { + rtt: Some(120.0), + floor: Some(100.0), + ..Default::default() + }; + // Both are 1.2x their floor, so both are fine - a fixed millisecond + // threshold would have called the second one bad. + assert_eq!(quality(&near), quality(&far)); + let p = palette(); + assert_eq!(colour_for(quality(&near), None, &p), p.ok); + assert_eq!(colour_for(quality(&far), None, &p), p.ok); + // Three times its own floor is congestion wherever it is. + let slow = Session { + rtt: Some(300.0), + floor: Some(100.0), + ..Default::default() + }; + assert_eq!(colour_for(quality(&slow), None, &p), p.bad); + // Loss outranks latency: a fast path dropping packets is not fine. + assert_eq!(colour_for(quality(&near), Some(2.5), &p), p.bad); + // Nothing measured yet is not a verdict. + assert_eq!(colour_for(None, None, &p), p.dim); + } + + /// Drop the colour, keep the cells - alignment is a question about text. + #[cfg(test)] + fn plain(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + for c in chars.by_ref() { + if c == 'm' { + break; + } + } + } else { + out.push(c); + } + } + out.trim_end().to_string() + } + + #[test] + fn a_row_is_laid_out_cell_for_cell() { + // Captured from link.py in an 85-column pty, with the address + // replaced by one from RFC 5737's documentation range - it is the + // same width, so the alignment this exists to check is unchanged, + // and this repository is public. The row is built from + // eight separate formats and the header is one fixed string, so + // nothing inside this file can catch a drift between them - only + // the other implementation can. This port had three cells of it, + // and every half looked plausible on its own. + let want = + "▐ 203.0.113.221:22 williamli 37ms 20ms 10ms 0.00% 11.1Mbps 1m"; + let row = Session { + peer: "203.0.113.221:22".into(), + ip: "203.0.113.221".into(), + port: 22, + rtt: Some(37.0), + jitter: Some(10.0), + floor: Some(20.0), + recent_loss: Some(0.0), + delivery: Some(11_100_000.0), + lastsnd: Some(60_000.0), + lastrcv: Some(90_000.0), + ..Default::default() + }; + let state = State { + rows: vec![row.clone()], + names: HashMap::from([( + "203.0.113.221".to_string(), + vec![("williamli".to_string(), "pts/0".to_string())], + )]), + history: HashMap::new(), + err: String::new(), + }; + // Nothing selected, so no row carries the highlight. + let drawn = table(&[row], &state, 86, None, &palette()); + assert_eq!(plain(&drawn[1]), want); + } + + #[test] + fn a_missing_reading_keeps_its_column() { + // A socket the kernel has no round trip for still has to leave the + // numbers to its right where they were, or the whole table shifts + // on one absent value. + let row = Session { + peer: "203.0.113.9:22".into(), + ip: "203.0.113.9".into(), + port: 22, + ..Default::default() + }; + let state = State { + rows: vec![row.clone()], + names: HashMap::new(), + history: HashMap::new(), + err: String::new(), + }; + let drawn = table(&[row], &state, 86, None, &palette()); + assert_eq!( + plain(&drawn[1]), + "▐ 203.0.113.9:22 -- -- -- -- -- --" + ); + } + + #[test] + fn condensing_keeps_the_typical_not_the_extreme() { + // Ten samples into two columns: each column is its half's median, + // so a single spike cannot define a column on its own. + let values = vec![10.0, 10.0, 10.0, 10.0, 400.0, 10.0, 10.0, 10.0, 10.0, 10.0]; + let got = condense(&values, 2); + assert_eq!(got, vec![10.0, 10.0]); + // Fewer samples than columns is left alone. + assert_eq!(condense(&[1.0, 2.0], 8), vec![1.0, 2.0]); + } + + #[test] + fn window_labels_are_short() { + assert_eq!(window_label(60.0), "1m"); + assert_eq!(window_label(900.0), "15m"); + assert_eq!(window_label(3600.0), "1h"); + assert_eq!(window_label(45.0), "45s"); + } + + #[test] + fn a_rising_series_climbs_the_canvas() { + // Eight samples across four cells - two dots each - from the bottom + // of the decade the axis covers to the top of it. + let values: Vec<f64> = (0..8).map(|i| 10f64.powf(1.0 + i as f64 / 7.0)).collect(); + let grid = braille_canvas(&values, 1.0, 2.0, 4, 4, 8); + let highest: Vec<usize> = (0..4) + .map(|x| { + grid.iter() + .position(|row| row[x] != 0) + .expect("every column carries part of the trace") + }) + .collect(); + // Row zero is the top of the canvas, so climbing counts down. + assert_eq!(highest.first(), Some(&3)); + assert_eq!(highest.last(), Some(&0)); + assert!(highest.windows(2).all(|p| p[0] >= p[1]), "{:?}", highest); + } + + #[test] + fn a_young_session_keeps_to_its_share_of_the_axis() { + // Four samples on an axis holding eight: half the width, against the + // right edge, because half the chart is older than the session is. + let grid = braille_canvas(&[10.0, 10.0, 10.0, 10.0], 0.0, 1.0, 4, 1, 8); + assert_eq!((grid[0][0], grid[0][1]), (0, 0)); + assert!(grid[0][2] != 0 && grid[0][3] != 0); + // The same four with the axis to themselves reach the left edge. + let full = braille_canvas(&[10.0, 10.0, 10.0, 10.0], 0.0, 1.0, 4, 1, 4); + assert!(full[0].iter().all(|m| *m != 0)); + } + + #[test] + fn the_scroll_label_keeps_its_width() { + // The detail footer is measured before the body is built, so this + // string has to be one width whatever the numbers are. A wider one + // could wrap the hints onto an extra line and leave the body sized + // against a footer that is no longer the footer being drawn. + let widths: Vec<usize> = [(1, 28, 35), (9, 36, 350), (100, 128, 999)] + .into_iter() + .map(|(a, b, c)| scroll_label(a, b, c).chars().count()) + .collect(); + assert!(widths.windows(2).all(|p| p[0] == p[1]), "{:?}", widths); + } + + #[test] + fn a_contested_cell_belongs_to_one_trace_and_shares_the_run() { + // Two flat traces contesting all four cells. This used to assert the + // union of their dots in the later session's colour; that is the bug + // the latency chart surfaced, where two hosts a few percent apart + // contest every cell and one was drawn wholly in the other's hue. + let top = braille_canvas(&[10.0; 8], 0.0, 1.0, 4, 1, 8); + let bottom = braille_canvas(&[1.0; 8], 0.0, 1.0, 4, 1, 8); + assert!(top[0][0] != 0 && bottom[0][0] != 0, "both must contest"); + let cells = overlay( + &[ + ("first".to_string(), top.clone()), + ("second".to_string(), bottom.clone()), + ], + 4, + 1, + ); + for x in 0..4 { + let (whose, mask) = &cells[0][x]; + let mine = if whose == "first" { top[0][x] } else { bottom[0][x] }; + assert_eq!(*mask, mine, "column {} carries the other trace's dots", x); + assert_ne!(*mask, top[0][x] | bottom[0][x], "column {} merged", x); + } + // The property rather than the phase: the run is shared, so neither + // trace is missing from it. Pinning the exact alternation here is + // what let the parity starvation through - it asserted the mechanism + // and called that the contract. + let owners: Vec<&str> = (0..4).map(|x| cells[0][x].0.as_str()).collect(); + assert!(owners.contains(&"first"), "{:?}", owners); + assert!(owners.contains(&"second"), "{:?}", owners); + } + + #[test] + fn a_command_that_will_not_run_says_so_rather_than_returning_nothing() { + // `run` folds failure into an empty string, and for `ss` that is the + // difference between a machine with nobody connected and a widget + // that cannot see. The poller puts this text on screen; before it + // existed, State.err was drawn but never assigned and a dead read + // rendered as "No inbound sessions". + let why = run_or(&["definitely-not-a-real-binary-xyz", "--version"]) + .expect_err("a missing binary must not read as empty output"); + // The wording belongs to the shared runner; what this widget needs + // is that the failure names the command and is not empty. + assert!(why.contains("definitely-not-a-real-binary-xyz"), "{}", why); + // A command that does run still comes back as Ok. + assert!(run_or(&["true"]).is_ok()); + // And one that runs and fails is an error, not empty output - the + // whole reason this returns a Result. + assert!(run_or(&["false"]).is_err()); + } + + #[test] + fn a_command_that_never_answers_is_given_up_on() { + // The one polling thread feeds every pane here, so an unbounded + // wait froze the widget on its last frame with nothing said. A + // second is enough to prove the bound; RUN_TIMEOUT itself is five, + // which is too long to spend in a test. + let began = std::time::Instant::now(); + let why = tc::run(&["sleep", "30"], 1).expect_err("a wedged child must not wait for ever"); + assert!(began.elapsed() < Duration::from_secs(20), "{:?}", began.elapsed()); + assert!(why.contains("did not answer"), "{}", why); + } + + #[test] + fn a_port_too_big_to_be_one_is_dropped_rather_than_wrapped() { + // `70000 as u16` is 4464, a real port belonging to somebody else: + // the widget would have called sessions on 4464 inbound and never + // looked at what was asked for. Nothing on screen could say so. + let cfg = serde_json::json!({"ports": [22, 70000, 65535, 4_294_967_296u64]}); + let got = configured_ports(&cfg); + assert!(!got.contains(&4464), "70000 wrapped into a different port: {:?}", got); + assert!(!got.contains(&0), "a multiple of 65536 wrapped to port 0: {:?}", got); + // The two that are ports survive, including the top of the range. + assert_eq!(got, vec![22, 65535]); + // A key that is absent or the wrong shape is simply no ports. + assert!(configured_ports(&serde_json::json!({})).is_empty()); + assert!(configured_ports(&serde_json::json!({"ports": 22})).is_empty()); + } + + #[test] + fn no_trace_is_starved_by_where_the_contention_falls() { + // Two traces that meet only in even-numbered cells, which is what a + // series with a gap in every other column produces - and the column + // width is free-form config, so it is reachable rather than + // theoretical. Ownership used to be `x % claims.len()`, so every + // contested cell shared a parity, the modulus picked the same + // claimant every time, and the other trace was absent from the whole + // stretch. That is the failure this function exists to prevent, one + // level in. + let a = vec![vec![0b0000_0001u8, 0, 0b0000_0001, 0]]; + let b = vec![vec![0b0100_0000u8, 0, 0b0100_0000, 0]]; + let cells = overlay(&[("first".to_string(), a), ("second".to_string(), b)], 4, 1); + let owners: Vec<&str> = (0..4) + .filter(|x| cells[0][*x].1 != 0) + .map(|x| cells[0][x].0.as_str()) + .collect(); + assert_eq!(owners.len(), 2, "both contested cells should be drawn"); + // The property, not the phase. Which of the two takes the first cell + // is an implementation detail; that neither vanishes is not. + assert!(owners.contains(&"first"), "{:?}", owners); + assert!(owners.contains(&"second"), "{:?}", owners); + } + +} diff --git a/widgets/src/bin/link_help.txt b/widgets/src/bin/link_help.txt new file mode 100644 index 0000000..fd7d30e --- /dev/null +++ b/widgets/src/bin/link_help.txt @@ -0,0 +1,36 @@ +How good the connection is between here and whoever is connected to it. + +Every other network widget in this repo measures a path it chose - ping these +hosts, watch that tailnet. This one measures the path *you* are on: the TCP +socket carrying your session, as the kernel already sees it. + +Nothing is sent. `ss -tin` reports what the kernel has measured for each +established socket - round-trip time and its variance, the best round trip it +has ever seen, retransmitted bytes, the delivery rate it actually achieved - +so this widget can describe the link without adding a single packet to it. + + link [-n SECONDS] + +Sessions are every established connection into a port this machine listens on, +which is SSH and anything else that accepts terminals. + +w cycles how much time the chart covers - a minute, five, fifteen, an hour. +Past a minute there are more samples than columns, so each column becomes the +median of its slice: a spike is still counted in the worst column and on the +detail screen, but the line itself smooths. Look at a stall on the short +window. + +Keys: up/down move the selection, and pressing up on the first session or +down on the last clears it, which puts the chart back to drawing every +session alike. Right or enter opens the selected one; left or esc comes back. +On that screen up/down scroll it and n/p step to the next or previous +session without returning to the list. w changes the span, o toggles idle +sessions, r refreshes, q quits. + +The chart is drawn on a braille dot canvas rather than one character per +sample, so it reads as a line rather than a column of marks. Sessions share +the one chart, so the selected row is drawn last and at full strength while +the rest are mixed toward the background: where two traces cross, the colour +goes to the one being looked at rather than to whichever sits lower in the +list. The others stay drawn rather than hidden, because a chart that showed +one session at a time would answer a different question. diff --git a/widgets/src/bin/matrix.rs b/widgets/src/bin/matrix.rs new file mode 100644 index 0000000..8912103 --- /dev/null +++ b/widgets/src/bin/matrix.rs @@ -0,0 +1,233 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Digital rain. +//! +//! The one widget in the collection that computes nothing at all. It just +//! looks good, and it knows it - and it is a fair test of the drawing path, +//! since it repaints every cell of every frame. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use toys_core as tc; + +const GLYPHS: &str = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789ABCDEF<>*+=$#%&@"; + +/// A tiny xorshift, because the rain does not need a crate to be random. +/// +/// Seeded from the clock, so two panes started together do not fall in +/// lockstep - which they would with a fixed seed, and which looks wrong +/// immediately. +struct Rng(u64); + +impl Rng { + fn new() -> Rng { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x2545F4914F6CDD1D); + Rng(seed | 1) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + + fn float(&mut self) -> f64 { + (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } + + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + self.float() * (hi - lo) + } + + fn below(&mut self, n: usize) -> usize { + if n == 0 { + 0 + } else { + (self.next() % n as u64) as usize + } + } +} + +struct Drop { + y: f64, + speed: f64, + length: usize, +} + +impl Drop { + fn new(h: usize, rng: &mut Rng) -> Drop { + Drop { + y: -rng.range(0.0, h as f64 * 1.5), + speed: rng.range(0.25, 1.15), + length: rng.below(std::cmp::max(6, h)) + std::cmp::max(4, h / 5), + } + } +} + +/// The trail's colour at a given distance from the head, 1 being closest. +fn shade(level: f64) -> String { + let g = (60.0 + 175.0 * level) as u8; + tc::rgb((10.0 + 20.0 * level) as u8, g, (30.0 + 50.0 * level) as u8) +} + +fn main() { + tc::maybe_help(include_str!("matrix_help.txt")); + let head = tc::rgb(210, 255, 225); + let near = tc::rgb(120, 255, 170); + let glyphs: Vec<char> = GLYPHS.chars().collect(); + let mut rng = Rng::new(); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let (mut w, mut h) = tc::size(); + let mut drops: Vec<Drop> = (0..w).map(|_| Drop::new(h, &mut rng)).collect(); + // Glyphs mutate in place, independently of the drops falling over them, + // which is what stops the rain reading as a repeating pattern. + let mut field: Vec<Vec<char>> = (0..h) + .map(|_| (0..w).map(|_| glyphs[rng.below(glyphs.len())]).collect()) + .collect(); + + loop { + for key in keyboard.poll() { + if key == "q" || key == "Q" { + keyboard.restore(); + tc::restore_screen(); + return; + } + } + let (nw, nh) = tc::size(); + if (nw, nh) != (w, h) { + w = nw; + h = nh; + drops = (0..w).map(|_| Drop::new(h, &mut rng)).collect(); + field = (0..h) + .map(|_| (0..w).map(|_| glyphs[rng.below(glyphs.len())]).collect()) + .collect(); + } + + // A handful of cells change character every frame, wherever they + // happen to be. + for _ in 0..(w * h / 40).max(1) { + let y = rng.below(h); + let x = rng.below(w); + field[y][x] = glyphs[rng.below(glyphs.len())]; + } + + let mut rows: Vec<String> = vec![String::new(); h]; + let mut cells: Vec<Vec<(String, char)>> = + vec![vec![(String::new(), ' '); w]; h]; + for (x, drop) in drops.iter_mut().enumerate() { + drop.y += drop.speed; + if drop.y - drop.length as f64 > h as f64 { + *drop = Drop::new(h, &mut rng); + drop.y = -(drop.length as f64); + } + for back in 0..drop.length { + let y = drop.y as isize - back as isize; + if y < 0 || y >= h as isize { + continue; + } + let level = 1.0 - (back as f64 / drop.length as f64); + let colour = if back == 0 { + head.clone() + } else if back == 1 { + near.clone() + } else { + shade(level) + }; + cells[y as usize][x] = (colour, field[y as usize][x]); + } + } + for (y, line) in cells.iter().enumerate() { + let parts: Vec<(&str, String)> = line + .iter() + .map(|(colour, ch)| { + ( + colour.as_str(), + if colour.is_empty() { + " ".to_string() + } else { + ch.to_string() + }, + ) + }) + .collect(); + rows[y] = tc::seg(&parts, w); + } + // The one piece of text on the screen. This widget is rain and + // nothing else, so a full footer would be the loudest thing in it - + // but a screen that answers a key and never says which is a screen + // you have to guess your way out of. One dim word in the corner, + // over the rain rather than instead of it. + if h > 0 { + let last = h - 1; + rows[last] = tc::seg( + &[ + (tc::rgb(70, 100, 80).as_str(), " [q]uit".to_string()), + (String::new().as_str(), " ".repeat(w.saturating_sub(7))), + ], + w, + ); + } + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(55)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_trail_fades_from_the_head() { + // Closer to the head is brighter; the far end is nearly dark. + let bright = shade(1.0); + let faint = shade(0.0); + assert_ne!(bright, faint); + assert!(bright.contains("235"), "head shade was {:?}", bright); + assert!(faint.contains("60"), "tail shade was {:?}", faint); + } + + #[test] + fn two_instances_do_not_fall_in_lockstep() { + let mut a = Rng::new(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let mut b = Rng::new(); + let left: Vec<u64> = (0..4).map(|_| a.next()).collect(); + let right: Vec<u64> = (0..4).map(|_| b.next()).collect(); + assert_ne!(left, right, "a fixed seed would sync every pane"); + } + + #[test] + fn random_values_stay_inside_their_bounds() { + let mut rng = Rng::new(); + for _ in 0..500 { + let f = rng.float(); + assert!((0.0..1.0).contains(&f)); + let r = rng.range(0.25, 1.15); + assert!((0.25..=1.15).contains(&r)); + assert!(rng.below(10) < 10); + } + assert_eq!(rng.below(0), 0, "an empty range must not divide by zero"); + } +} diff --git a/widgets/src/bin/matrix_help.txt b/widgets/src/bin/matrix_help.txt new file mode 100644 index 0000000..b404ac4 --- /dev/null +++ b/widgets/src/bin/matrix_help.txt @@ -0,0 +1,7 @@ +Digital rain. + +Falling glyphs with truecolor fade trails: near-white head, bright green +shoulder, and a smooth decay over each drop's length. Glyphs mutate in place +independently of the drops, and the field reflows on terminal resize. + + matrix diff --git a/widgets/src/bin/netwatch.rs b/widgets/src/bin/netwatch.rs new file mode 100644 index 0000000..ccab4e9 --- /dev/null +++ b/widgets/src/bin/netwatch.rs @@ -0,0 +1,2838 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Which processes are using the network, how much, and how fast. +//! +//! A port of netwatch.py, reading the same two things: `ss -tine` for the +//! per-socket byte counters and the inode beside them, and /proc/<pid>/fd +//! for the process that owns the inode. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use toys_core as tc; + +const SERIES: usize = 240; + +/// A braille cell is two dots wide and four tall, so one character holds +/// eight addressable points. The bit for each is fixed by the encoding. +const BRAILLE: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]; + +/// Interfaces that are not the wire. A packet forwarded out of one of these +/// leaves through a real interface as well, and counting both counts it +/// twice. +const VIRTUAL: &[&str] = &[ + "lo", "tailscale0", "docker", "veth", "br-", "virbr", "wg", "tun", "tap", "cni", "flannel", + "kube", +]; + +/// Systemd names the slice, not the thing in it. +const SLICES: &[&str] = &["system.slice", "user.slice", "init.scope", "-.slice", "app.slice"]; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// Decimal units, as network equipment and ISPs quote them. +fn units(n: f64) -> String { + for (suffix, scale) in [("GB", 1e9), ("MB", 1e6), ("KB", 1e3)] { + if n >= scale { + return format!("{:.1} {}", n / scale, suffix); + } + } + format!("{} B", n as i64) +} + +fn rate(n: f64) -> String { + if n > 0.0 { + format!("{}/s", units(n)) + } else { + "-".into() + } +} + +fn elapsed(seconds: f64) -> String { + let s = seconds as i64; + if s < 60 { + format!("{}s", s) + } else if s < 3600 { + format!("{}m {:02}s", s / 60, s % 60) + } else { + format!("{}h {:02}m", s / 3600, (s % 3600) / 60) + } +} + +/// Seconds before an external command is given up on, from netwatch.py. +const RUN_TIMEOUT: u64 = 5; + +fn run(args: &[&str]) -> String { + // Bounded: .output() waits forever, and a wedged child used to freeze + // the poll thread with the pane still showing its last frame. + tc::run(args, RUN_TIMEOUT).unwrap_or_default() +} + +/// Every address this machine answers to. +/// +/// A connection to one of them is turned around inside the kernel and never +/// reaches a wire, so it is not traffic leaving the machine even though the +/// address is not loopback. +fn own_addresses() -> Vec<String> { + let mut found = Vec::new(); + for line in run(&["ip", "-o", "addr"]).lines() { + let cols: Vec<&str> = line.split_whitespace().collect(); + if let Some(at) = cols.iter().position(|c| *c == "inet" || *c == "inet6") { + if let Some(addr) = cols.get(at + 1) { + found.push(addr.split('/').next().unwrap_or(addr).to_string()); + } + } + } + found +} + +/// Whether this traffic never leaves the machine. +fn local_peer(host: &str, own: &[String]) -> bool { + if host.starts_with("127.") || host == "::1" || host.is_empty() || host == "*" { + return true; + } + let bare = host.strip_prefix("::ffff:").unwrap_or(host); + own.iter().any(|a| a == bare) +} + +/// Whether a peer is out on the internet rather than nearby. +fn off_box(host: &str, own: &[String]) -> bool { + if local_peer(host, own) { + return false; + } + let h = host.strip_prefix("::ffff:").unwrap_or(host); + if h.starts_with("10.") || h.starts_with("192.168.") || h.starts_with("169.254.") { + return false; + } + if h.starts_with("172.") { + if let Some(second) = h.split('.').nth(1).and_then(|s| s.parse::<u16>().ok()) { + if (16..=31).contains(&second) { + return false; + } + } + } + if let Some(second) = h.strip_prefix("100.").and_then(|r| r.split('.').next()) { + if let Ok(n) = second.parse::<u16>() { + if (64..=127).contains(&n) { + return false; + } + } + } + !(h.starts_with("fd7a:115c:a1e0") + || h.starts_with("fe80:") + || h.starts_with("fc") + || h.starts_with("fd")) +} + +#[derive(Clone)] +struct Seen { + sent: u64, + recv: u64, + peer: String, + port: u16, + /// The local port - `ss` column 3, where column 4 is the peer's. Five + /// sockets to one CDN all read "1.2.3.4:443", and this is the only field + /// that tells them apart: the peer's port is 443 on every one of them. + mine: u16, + cgroup: String, +} + +/// Every TCP socket's byte counters, keyed by inode. +/// +/// -i for the counters, -e for the inode. Without the inode there is no +/// honest way to reach the process: `ss -p` needs root to name anybody +/// else's, while /proc/<pid>/fd needs nothing to name our own. +fn sockets(external: bool, own: &[String]) -> (HashMap<String, Seen>, String) { + let text = run(&["ss", "-tine"]); + if text.is_empty() { + return (HashMap::new(), "ss would not run".into()); + } + let mut found = HashMap::new(); + let (mut inode, mut peer, mut port, mut cgroup) = (None, String::new(), 0u16, String::new()); + let mut mine = 0u16; + for (i, line) in text.lines().enumerate() { + if i == 0 { + continue; + } + // A socket is two lines: the addresses and inode, then the counters + // on an indented continuation. Neither is usable without the other. + if !line.starts_with(' ') && !line.starts_with('\t') { + let cols: Vec<&str> = line.split_whitespace().collect(); + peer = cols + .get(4) + .and_then(|a| a.rsplit_once(':')) + .map(|(h, _)| h.trim_matches(|c| c == '[' || c == ']').to_string()) + .unwrap_or_default(); + port = cols + .get(4) + .and_then(|a| a.rsplit_once(':')) + .and_then(|(_, p)| p.parse().ok()) + .unwrap_or(0); + // Column 3 is our address, column 4 is theirs. + mine = cols + .get(3) + .and_then(|a| a.rsplit_once(':')) + .and_then(|(_, p)| p.parse().ok()) + .unwrap_or(0); + inode = field(line, "ino:").filter(|v| v != "0"); + cgroup = field(line, "cgroup:").unwrap_or_default(); + continue; + } + let id = match inode.take() { + Some(id) => id, + None => continue, + }; + if local_peer(&peer, own) || (external && !off_box(&peer, own)) { + continue; + } + found.insert( + id, + Seen { + sent: field(line, "bytes_sent:") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + recv: field(line, "bytes_received:") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + peer: peer.clone(), + port, + mine, + cgroup: cgroup.clone(), + }, + ); + } + (found, String::new()) +} + +/// The value after `key:` on a line, up to the next space. +fn field(line: &str, key: &str) -> Option<String> { + let at = line.find(key)? + key.len(); + let rest = &line[at..]; + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let value = &rest[..end]; + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +/// Who owns a socket, from the cgroup the kernel already reports. +/// +/// Another user's /proc is closed, but `ss` prints the control group for +/// every socket regardless, and on a systemd machine that names the unit. +fn unit_name(cgroup: &str) -> String { + for part in cgroup.trim_matches('/').split('/').rev() { + if part.is_empty() || SLICES.contains(&part) { + continue; + } + let mut name = part; + for suffix in [".service", ".scope", ".slice"] { + if let Some(base) = name.strip_suffix(suffix) { + name = base; + break; + } + } + // A login session is a person, not a program. + if name.starts_with("session-") || name.starts_with("user-") { + continue; + } + return name.to_string(); + } + String::new() +} + +/// inode -> (pid, name), for every process this user can read. +fn socket_owners() -> HashMap<String, (i32, String)> { + let mut owners = HashMap::new(); + let entries = match std::fs::read_dir("/proc") { + Ok(e) => e, + Err(_) => return owners, + }; + for entry in entries.flatten() { + let pid: i32 = match entry.file_name().to_string_lossy().parse() { + Ok(p) => p, + Err(_) => continue, + }; + let fds = match std::fs::read_dir(format!("/proc/{}/fd", pid)) { + Ok(f) => f, + Err(_) => continue, + }; + let mut name = String::new(); + for fd in fds.flatten() { + if let Ok(target) = std::fs::read_link(fd.path()) { + let target = target.to_string_lossy(); + if let Some(rest) = target.strip_prefix("socket:[") { + if name.is_empty() { + name = process_name(pid); + } + owners.insert(rest.trim_end_matches(']').to_string(), (pid, name.clone())); + } + } + } + } + owners +} + +/// What to call a process, preferring something a person would recognise. +/// +/// /proc/<pid>/comm is the kernel's answer and usually right, but some +/// binaries are versioned - .../claude/versions/2.1.233 reports itself as +/// "2.1.233", which is true and useless. +fn process_name(pid: i32) -> String { + let comm = std::fs::read_to_string(format!("/proc/{}/comm", pid)) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + if comm.chars().any(|c| c.is_ascii_alphabetic()) { + return comm; + } + const GENERIC: &[&str] = &[ + "versions", "bin", "sbin", "libexec", "node_modules", "dist", "build", "lib", "share", + "local", "current", "releases", + ]; + let argv0 = std::fs::read(format!("/proc/{}/cmdline", pid)) + .map(|raw| { + String::from_utf8_lossy(&raw) + .split('\0') + .next() + .unwrap_or("") + .to_string() + }) + .unwrap_or_default(); + for part in argv0.split('/').rev() { + if part.chars().any(|c| c.is_ascii_alphabetic()) + && !GENERIC.contains(&part.to_lowercase().as_str()) + { + return part.to_string(); + } + } + if comm.is_empty() { + "?".into() + } else { + comm + } +} + +/// Bytes in and out of this machine's real interfaces. +/// +/// The kernel counts these whatever produced them, which is the point: a +/// packet this machine routes rather than terminates never touches a +/// socket. On an exit node that is most of the traffic. +fn wire_bytes() -> Option<(u64, u64, Vec<String>)> { + let text = std::fs::read_to_string("/proc/net/dev").ok()?; + let (mut rx, mut tx, mut names) = (0u64, 0u64, Vec::new()); + for line in text.lines().skip(2) { + let (name, rest) = line.split_once(':')?; + let name = name.trim(); + if VIRTUAL.iter().any(|v| name.starts_with(v)) { + continue; + } + let fields: Vec<&str> = rest.split_whitespace().collect(); + if fields.len() < 9 { + continue; + } + rx += fields[0].parse::<u64>().unwrap_or(0); + tx += fields[8].parse::<u64>().unwrap_or(0); + names.push(name.to_string()); + } + Some((rx, tx, names)) +} + +/// Which interfaces are being counted, for the end of the line. +fn wire_label(names: &[String]) -> String { + match names.len() { + 0 => String::new(), + 1..=3 => names.join(", "), + n => format!("{} of them", n), + } +} + +/// How the detail screen reports where you are in it, at a width that does +/// not change with the numbers - the footer is packed before the body is +/// measured, so a label that grew by a character could wrap the hints onto +/// another line and leave the body sized for a footer that is not there. +/// +/// link.rs carries the same function; TOY-7 tracks folding the pair into +/// toys-core rather than fixing anything here twice. +fn scroll_label(first: usize, last: usize, total: usize) -> String { + format!("rows {:>3}-{:>3} of {:>3}", first, last, total) +} + +/// How long a rate is averaged over. +/// +/// One sample interval is the honest instantaneous rate and an unreadable +/// column: nearly all traffic is bursty, so a process that is steadily busy +/// flickers between a figure and a dash. Averaging over a few seconds is +/// just as true - it is a rate over a stated window - and can actually be +/// read. The header says which window, so the number is not a mystery. +const RATE_WINDOW: f64 = 10.0; + +/// Fold one sample into a counter and re-average over the window. +/// +/// A macro rather than a trait because processes, connections and endpoints +/// carry the same six fields and want exactly the same arithmetic; the one +/// thing that must not happen is the three drifting apart on what a rate +/// means. +macro_rules! fold { + ($e:expr, $when:expr, $gap:expr, $up:expr, $down:expr) => {{ + let e = &mut $e; + e.up += $up; + e.down += $down; + e.seen = $when; + e.alive = true; + e.recent.push(($when, $gap, $up, $down)); + e.recent.retain(|(t, _, _, _)| $when - t <= RATE_WINDOW); + // Each delta covers the interval ending at its stamp and lasting + // its own gap, so the time the window covers is the sum of those + // gaps - counting each stamp once, because a process's fifteen + // sockets fold at one stamp and share one interval between them. + // Dividing instead by the span from oldest stamp to newest counted + // n deltas over n-1 intervals, and every process, connection and + // endpoint row read a quarter high at a one-second cadence. + let mut span = 0.0f64; + let mut last = f64::NAN; + let (mut u, mut d) = (0u64, 0u64); + for (t, gap, up, down) in &e.recent { + if *t != last { + span += gap; + last = *t; + } + u += up; + d += down; + } + // A process with fifteen sockets folds fifteen samples at one + // timestamp, and the window then spans no time at all. Dividing by + // an epsilon turned five megabytes into a rate of a terabyte a + // second and pinned the chart's axis there for four minutes. With + // no elapsed time there is no rate to compute, so the last one + // stands until the next sample gives the window a width. + if span > 0.0 { + e.up_rate = u as f64 / span; + e.down_rate = d as f64 / span; + } + }}; +} + +/// Drop what has fallen out of the window, and say so when nothing is left. +macro_rules! settle { + ($e:expr, $when:expr) => {{ + let e = &mut $e; + e.alive = false; + e.recent.retain(|(t, _, _, _)| $when - t <= RATE_WINDOW); + if e.recent.is_empty() { + e.up_rate = 0.0; + e.down_rate = 0.0; + } + }}; +} + +/// Keep a series bounded without reallocating the whole thing each sample. +fn trim<T>(series: &mut Vec<T>, most: usize) { + if series.len() > most { + let drop = series.len() - most; + series.drain(..drop); + } +} + +#[derive(Clone, Default)] +struct Proc { + pid: i32, + name: String, + up: u64, + down: u64, + up_rate: f64, + down_rate: f64, + alive: bool, + seen: f64, + /// (when, up bytes, down bytes) for the last few samples. + /// (stamp, the gap it covers, bytes up, bytes down) + recent: Vec<(f64, f64, u64, u64)>, + /// (down rate, up rate) per sample, for this process's own chart. + hist: Vec<(f64, f64)>, + /// (read rate, write rate) per sample, from /proc/<pid>/io. + /// + /// Disk was a line of two lifetime totals, which answers "has it ever + /// touched the disk" and not "is it touching it now" - the question the + /// network half of this screen exists to answer. Same series, same + /// chart, so the two can be read against each other. + disk: Vec<(f64, f64)>, + /// (when, read bytes, write bytes) at the previous sample. The kernel + /// reports lifetime counters, so a rate needs the one before. + io_was: Option<(f64, u64, u64)>, +} + +/// One socket, so the detail screen can say which of a process's dozen +/// connections is the one actually moving. +#[derive(Clone, Default)] +struct Conn { + pid: i32, + name: String, + peer: String, + port: u16, + /// The local port. See `Seen::mine`. + mine: u16, + up: u64, + down: u64, + up_rate: f64, + down_rate: f64, + alive: bool, + seen: f64, + /// (stamp, the gap it covers, bytes up, bytes down) + recent: Vec<(f64, f64, u64, u64)>, + /// (down rate, up rate) per sample, so a selected socket can be charted + /// the way a selected endpoint is. + hist: Vec<(f64, f64)>, +} + +/// The sockets sharing a peer, folded together: a browser opening six +/// connections to one host is one thing being talked to, not six. +#[derive(Clone, Default)] +struct Spot { + pid: i32, + name: String, + peer: String, + up: u64, + down: u64, + up_rate: f64, + down_rate: f64, + alive: bool, + seen: f64, + ports: BTreeSet<u16>, + /// (stamp, the gap it covers, bytes up, bytes down) + recent: Vec<(f64, f64, u64, u64)>, + hist: Vec<(f64, f64)>, +} + +#[derive(Default)] +struct State { + totals: HashMap<(i32, String), Proc>, + conns: HashMap<String, Conn>, + spots: HashMap<(i32, String, String), Spot>, + last: HashMap<String, (u64, u64)>, + series: Vec<(f64, f64, f64, f64)>, + stamp: f64, + started: f64, + wire: Option<(u64, u64)>, + wire_rate: (f64, f64), + wire_names: Vec<String>, + err: String, +} + +fn sample(state: &mut State, external: bool) { + let stamp = now(); + let own = own_addresses(); + let counters = wire_bytes(); + let (found, err) = sockets(external, &own); + let owners = if found.is_empty() { + HashMap::new() + } else { + socket_owners() + }; + absorb(state, stamp, &found, &owners, counters, err); +} + +/// Fold one reading into the running totals. +/// +/// Split from `sample` so the arithmetic can be exercised without a machine +/// that happens to have the right sockets open on it. +fn absorb( + state: &mut State, + stamp: f64, + found: &HashMap<String, Seen>, + owners: &HashMap<String, (i32, String)>, + counters: Option<(u64, u64, Vec<String>)>, + err: String, +) { + // A failed `ss` is not a machine with no sockets, and the difference + // matters more than it looks: this used to say why and then go on to + // overwrite `state.last` with the empty map it had been handed. The + // next good poll found no baseline for a socket that had been open for + // hours, counted its whole lifetime as one interval's traffic, and + // divided that by the gap since the failed poll. The number that + // reached the screen was not merely wrong, it was enormous, and it + // stayed in the totals afterwards. + // + // `state.last` and `state.stamp` are one baseline and have to move + // together: keeping the counters while advancing the clock would divide + // two intervals of traffic by one interval of time, which is the same + // bug with a smaller number on it. So a failed reading advances + // neither - it records why and changes nothing else. + state.err = err; + if !state.err.is_empty() { + return; + } + let gap = if state.stamp > 0.0 { + (stamp - state.stamp).max(1e-6) + } else { + 0.0 + }; + + // A row with nothing left in the window really is idle, and says so. + for row in state.totals.values_mut() { + settle!(*row, stamp); + } + for conn in state.conns.values_mut() { + settle!(*conn, stamp); + } + for spot in state.spots.values_mut() { + settle!(*spot, stamp); + } + + let first = state.stamp == 0.0; + for (inode, seen) in found { + let was = state.last.get(inode).copied(); + // A socket opened since the last sample started at zero when it was + // created, so all of its counters are traffic that happened while + // we were watching. Only sockets already open at the first sample + // are zeroed - the difference is a connection that opens and closes + // inside one interval, whose bytes would otherwise never count. + let (d_sent, d_recv) = if first { + (0, 0) + } else { + match was { + None => (seen.sent, seen.recv), + // A reused inode reads lower than it did; subtracting would + // underflow, so the new socket's own figures are the delta. + Some((s, r)) if seen.sent < s || seen.recv < r => (seen.sent, seen.recv), + Some((s, r)) => (seen.sent - s, seen.recv - r), + } + }; + let (pid, name) = match owners.get(inode) { + Some((pid, name)) => (*pid, name.clone()), + None => { + let unit = unit_name(&seen.cgroup); + ( + 0, + if unit.is_empty() { + "(unattributed)".into() + } else { + unit + }, + ) + } + }; + let row = state + .totals + .entry((pid, name.clone())) + .or_insert_with(|| Proc { + pid, + name: name.clone(), + ..Default::default() + }); + row.alive = true; + row.seen = stamp; + if gap > 0.0 { + fold!(*row, stamp, gap, d_sent, d_recv); + } + + let conn = state.conns.entry(inode.clone()).or_insert_with(|| Conn { + pid, + name: name.clone(), + peer: seen.peer.clone(), + port: seen.port, + mine: seen.mine, + ..Default::default() + }); + conn.alive = true; + conn.seen = stamp; + if gap > 0.0 { + fold!(*conn, stamp, gap, d_sent, d_recv); + } + + let spot = state + .spots + .entry((pid, name.clone(), seen.peer.clone())) + .or_insert_with(|| Spot { + pid, + name: name.clone(), + peer: seen.peer.clone(), + ..Default::default() + }); + spot.alive = true; + spot.seen = stamp; + spot.ports.insert(seen.port); + if gap > 0.0 { + fold!(*spot, stamp, gap, d_sent, d_recv); + } + } + + // Both totals are recorded every sample, so the filter is a display + // choice: o flips instantly and the chart redraws over history it + // already had rather than starting again. + if gap > 0.0 { + let mine_down: f64 = state + .totals + .values() + .filter(|r| r.pid != 0) + .map(|r| r.down_rate) + .sum(); + let mine_up: f64 = state + .totals + .values() + .filter(|r| r.pid != 0) + .map(|r| r.up_rate) + .sum(); + let all_down: f64 = state.totals.values().map(|r| r.down_rate).sum(); + let all_up: f64 = state.totals.values().map(|r| r.up_rate).sum(); + state.series.push((mine_down, mine_up, all_down, all_up)); + trim(&mut state.series, SERIES); + // Every process and every endpoint keeps its own series, so the + // second screen can draw one of them alone on the same axes. + for row in state.totals.values_mut() { + row.hist.push((row.down_rate, row.up_rate)); + trim(&mut row.hist, SERIES); + // /proc/<pid>/io is one small read beside the /proc/<pid>/fd + // directory scan this widget already does per process, and it + // is unreadable for anyone else's, which the empty map covers. + let (mut read_rate, mut write_rate) = (0.0, 0.0); + if row.pid != 0 && row.alive { + let io = proc_io(row.pid); + let now_read = *io.get("read_bytes").unwrap_or(&0); + let now_write = *io.get("write_bytes").unwrap_or(&0); + if !io.is_empty() { + if let Some((was_at, was_read, was_write)) = row.io_was { + let span = stamp - was_at; + if span > 0.0 { + read_rate = now_read.saturating_sub(was_read) as f64 / span; + write_rate = now_write.saturating_sub(was_write) as f64 / span; + } + } + row.io_was = Some((stamp, now_read, now_write)); + } + } + row.disk.push((read_rate, write_rate)); + trim(&mut row.disk, SERIES); + } + for conn in state.conns.values_mut() { + conn.hist.push((conn.down_rate, conn.up_rate)); + trim(&mut conn.hist, SERIES); + } + for spot in state.spots.values_mut() { + spot.hist.push((spot.down_rate, spot.up_rate)); + trim(&mut spot.hist, SERIES); + } + } + + if let Some((rx, tx, names)) = counters { + if let Some((was_rx, was_tx)) = state.wire { + if gap > 0.0 { + state.wire_rate = ( + rx.saturating_sub(was_rx) as f64 / gap, + tx.saturating_sub(was_tx) as f64 / gap, + ); + } + } + state.wire = Some((rx, tx)); + state.wire_names = names; + } + + state.last = found.iter().map(|(k, v)| (k.clone(), (v.sent, v.recv))).collect(); + state.stamp = stamp; + + // A closed connection is worth keeping - it may be the one that did the + // damage - but not forever. The quiet dead ones go once there are + // enough of them to matter. + if state.conns.len() > 400 { + let mut order: Vec<(String, f64, bool)> = state + .conns + .iter() + .map(|(k, c)| (k.clone(), c.seen, c.alive)) + .collect(); + order.sort_by(|a, b| a.1.total_cmp(&b.1)); + for (inode, _, alive) in order.into_iter().take(100) { + if !alive { + state.conns.remove(&inode); + } + } + } +} + +/// Plot a series on a dot canvas eight times finer than the cells. +/// +/// Two dots per column and four per row, which is the difference between a +/// line that steps between character rows and one that reads as a curve. +fn braille_canvas(values: &[f64], peak: f64, cols: usize, rows: usize, inverted: bool) -> Vec<Vec<u8>> { + let (px_w, px_h) = (cols * 2, rows * 4); + let mut grid = vec![vec![0u8; cols]; rows]; + let vals: Vec<f64> = values.iter().rev().take(px_w).rev().copied().collect(); + if vals.is_empty() { + return grid; + } + let point = |i: usize| -> (i64, i64) { + let x = if vals.len() == 1 { + px_w as i64 - 1 + } else { + ((i as f64) * (px_w as f64 - 1.0) / (vals.len() as f64 - 1.0)).round() as i64 + }; + let scaled = if peak > 0.0 { + (vals[i] / peak).clamp(0.0, 1.0) + } else { + 0.0 + }; + let magnitude = (scaled * (px_h as f64 - 1.0)).round() as i64; + (x, if inverted { magnitude } else { px_h as i64 - 1 - magnitude }) + }; + let dot = |x: i64, y: i64, grid: &mut Vec<Vec<u8>>| { + if x >= 0 && (x as usize) < px_w && y >= 0 && (y as usize) < px_h { + grid[y as usize / 4][x as usize / 2] |= BRAILLE[y as usize % 4][x as usize % 2]; + } + }; + if vals.len() == 1 { + if vals[0] > 0.0 { + let (x, y) = point(0); + dot(x, y, &mut grid); + } + return grid; + } + for i in 1..vals.len() { + // An idle stretch draws nothing at all rather than a flat line + // pinned to the axis, which would read as activity at zero. + if vals[i - 1] == 0.0 && vals[i] == 0.0 { + continue; + } + let (mut x0, mut y0) = point(i - 1); + let (x1, y1) = point(i); + let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs()); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + dot(x0, y0, &mut grid); + if x0 == x1 && y0 == y1 { + break; + } + let twice = 2 * err; + if twice >= dy { + err += dy; + x0 += sx; + } + if twice <= dx { + err += dx; + y0 += sy; + } + } + } + grid +} + +fn braille_row(masks: &[u8], colour: &str) -> Vec<(String, String)> { + masks + .iter() + .map(|m| { + ( + colour.to_string(), + if *m == 0 { + " ".to_string() + } else { + char::from_u32(0x2800 + *m as u32).unwrap_or(' ').to_string() + }, + ) + }) + .collect() +} + +// ── the second screen ──────────────────────────────────────────────── +// +// The table answers "what is using the network"; this answers "with what, +// and what is it writing". Everything here comes out of /proc for our own +// processes and out of nothing at all for anybody else's, which is why the +// screen says so rather than showing empty lists. + +/// Reverse DNS, off the drawing thread. +/// +/// A PTR lookup takes half a second when it works and longer when it does +/// not, which is several frames. The address is shown until a name arrives, +/// and an address that has no name is remembered as having none so it is +/// not asked about again every second. +struct Resolver { + known: Arc<Mutex<HashMap<String, String>>>, + wanted: Arc<Mutex<Vec<String>>>, +} + +impl Resolver { + fn new() -> Resolver { + let known: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new())); + let wanted: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); + let (k, q) = (Arc::clone(&known), Arc::clone(&wanted)); + std::thread::spawn(move || loop { + let next = q.lock().ok().and_then(|mut g| g.pop()); + let ip = match next { + Some(ip) => ip, + None => { + std::thread::sleep(Duration::from_millis(300)); + continue; + } + }; + // getent rather than a resolver library: it is glibc's own + // lookup, so it honours /etc/hosts, nsswitch and the search + // domains exactly as everything else on the machine does. + let answer = run(&["getent", "hosts", &ip]); + let name = answer + .split_whitespace() + .nth(1) + .unwrap_or("") + .to_string(); + if let Ok(mut guard) = k.lock() { + guard.insert(ip, name); + } + }); + Resolver { known, wanted } + } + + /// A name for an address if one is known, queueing a lookup if not. + fn name(&self, ip: &str) -> String { + if let Ok(guard) = self.known.lock() { + if let Some(found) = guard.get(ip) { + return found.clone(); + } + } + if let Ok(mut queue) = self.wanted.lock() { + if !queue.iter().any(|q| q == ip) && queue.len() < 64 { + queue.push(ip.to_string()); + } + } + String::new() + } +} + +/// What a port number is conventionally for, from /etc/services. +fn service(port: u16) -> String { + if port == 0 { + return String::new(); + } + let text = match std::fs::read_to_string("/etc/services") { + Ok(t) => t, + Err(_) => return String::new(), + }; + let want = format!("{}/tcp", port); + for line in text.lines() { + let line = line.split('#').next().unwrap_or(""); + let mut cols = line.split_whitespace(); + let (name, entry) = (cols.next(), cols.next()); + if entry == Some(want.as_str()) { + return name.unwrap_or("").to_string(); + } + } + String::new() +} + +/// Whether the process still exists. +/// +/// Distinct from the `alive` flag on a row, which means "had a socket in +/// the last sample". A long-running server sitting idle has neither traffic +/// nor open connections and has certainly not exited, and saying it had +/// would be worse than saying nothing. +fn running(pid: i32) -> bool { + pid > 0 && std::path::Path::new(&format!("/proc/{}", pid)).is_dir() +} + +/// Disk bytes this process has read and written, from /proc/<pid>/io. +fn proc_io(pid: i32) -> HashMap<String, u64> { + let mut out = HashMap::new(); + if let Ok(text) = std::fs::read_to_string(format!("/proc/{}/io", pid)) { + for line in text.lines() { + if let Some((key, value)) = line.split_once(':') { + if let Ok(n) = value.trim().parse() { + out.insert(key.trim().to_string(), n); + } + } + } + } + out +} + +struct OpenFile { + path: String, + size: u64, +} + +/// Regular files this process has open, largest first. +/// +/// A download has to land somewhere, and where it lands is a file getting +/// bigger. This is the closest thing to "which file" that exists outside +/// the encrypted stream - the name of the thing being written, rather than +/// the name of the thing being fetched. +fn open_files(pid: i32) -> Vec<OpenFile> { + let mut found = Vec::new(); + let dir = match std::fs::read_dir(format!("/proc/{}/fd", pid)) { + Ok(d) => d, + Err(_) => return found, + }; + for entry in dir.flatten() { + let link = match std::fs::read_link(entry.path()) { + Ok(l) => l, + Err(_) => continue, + }; + let path = link.to_string_lossy().to_string(); + if !path.starts_with('/') + || path.starts_with("/dev/") + || path.starts_with("/proc/") + || path.starts_with("/sys/") + { + continue; + } + // Through the fd rather than the path: the target may have been + // unlinked, and a temporary file being written is exactly the case + // this screen exists for. + let size = match std::fs::metadata(entry.path()) { + Ok(m) => m.len(), + Err(_) => continue, + }; + found.push(OpenFile { path, size }); + } + found.sort_by(|a, b| b.size.cmp(&a.size)); + found +} + +#[derive(Default)] +struct Facts { + cmdline: String, + cwd: String, +} + +/// Command and directory - what the table has no room for. +fn process_facts(pid: i32) -> Facts { + let mut facts = Facts::default(); + if let Ok(raw) = std::fs::read(format!("/proc/{}/cmdline", pid)) { + facts.cmdline = String::from_utf8_lossy(&raw) + .replace('\0', " ") + .trim() + .to_string(); + } + if let Ok(link) = std::fs::read_link(format!("/proc/{}/cwd", pid)) { + facts.cwd = link.to_string_lossy().to_string(); + } + facts +} + +/// A path that fits, keeping the end - which is the filename. +fn short(path: &str, room: usize) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + let path = if !home.is_empty() && path.starts_with(&home) { + format!("~{}", &path[home.len()..]) + } else { + path.to_string() + }; + let chars: Vec<char> = path.chars().collect(); + if chars.len() <= room || room < 2 { + return path; + } + format!("…{}", chars[chars.len() - (room - 1)..].iter().collect::<String>()) +} + +fn wrap(text: &str, width: usize) -> Vec<String> { + let mut lines = Vec::new(); + let mut rest: Vec<char> = text.chars().collect(); + while !rest.is_empty() && lines.len() < 3 { + if rest.len() <= width { + lines.push(rest.iter().collect()); + break; + } + let cut = rest[..(width + 1).min(rest.len())] + .iter() + .rposition(|c| *c == ' ') + .filter(|c| *c > width / 2) + .unwrap_or(width); + lines.push(rest[..cut].iter().collect()); + rest = rest[cut..].iter().skip_while(|c| **c == ' ').copied().collect(); + } + if lines.is_empty() { + vec![String::new()] + } else { + lines + } +} + +fn field_rows(label: &str, value: &str, w: usize, colour: &str, p: &Palette) -> Vec<String> { + wrap(value, ((w - 3).saturating_sub(10)).max(8)) + .into_iter() + .enumerate() + .map(|(i, line)| { + tc::seg( + &[ + ( + p.dim.as_str(), + format!(" {}", tc::pad(if i == 0 { label } else { "" }, 10)), + ), + (colour, line), + ], + w - 1, + ) + }) + .collect() +} + +/// A section header that says whether it is the one taking the keys. +fn section_head( + name: &str, + count: usize, + note: &str, + focused: bool, + next: bool, + w: usize, + p: &Palette, +) -> String { + tc::seg( + &[ + ( + if focused { p.accent.as_str() } else { p.lbl.as_str() }, + format!("{}── {} ── ", if focused { " ▏" } else { " " }, name), + ), + ( + p.dim.as_str(), + format!("{} {}{}", count, note, if count == 1 { "" } else { "s" }), + ), + // One key, on the one heading it is true of. This used to name + // a letter per section - [e] and [f] pointing at keys that no + // longer existed, and the middle one pointing at tab, which was + // the only one that was ever true. So tab is what is left, and + // it is shown only on the section tab would actually focus + // next: it cycles, and a [tab] on all three would promise three + // sections that one press reaches. + ( + p.accent.as_str(), + if next { " [tab] to focus".to_string() } else { String::new() }, + ), + ], + w - 1, + ) +} + +/// The line above a chart: what it is, and how far back it reaches. +fn chart_head(len: usize, w: usize, label: &str, interval: f64, p: &Palette) -> String { + let span = if len > 0 { + elapsed(len as f64 * interval) + } else { + "nothing yet".to_string() + }; + tc::seg( + &[ + (p.lbl.as_str(), format!(" ── {} ── ", label)), + (p.up.as_str(), "↑ tx above".into()), + (p.dim.as_str(), " · ".into()), + (p.down.as_str(), "↓ rx below".into()), + (p.dim.as_str(), format!(" · {} of history", span)), + ], + w - 1, + ) +} + +/// The three lists each own a flexible first column and a fixed tail. The +/// width lives in a function so the heading and the rows read it from the +/// same place: a heading that has slipped a column is worse than no heading, +/// because it labels the wrong number with confidence. +fn endpoint_host_w(w: usize) -> usize { + // 44, not 42: the fixed tail is 9 for ports, 10 for rx, 11 for tx and 11 + // for the rate, plus 3 for the selection mark. It was written as 42, so + // between 59 and 77 columns the host name took two cells the rate needed + // and seg() clipped "12.4 KB/s" down to "12.4 KB". A rate short of its + // unit is not a smaller rate, it is a wrong one. + ((w - 1).saturating_sub(44)).clamp(14, 34) +} + +fn connection_host_w(w: usize) -> usize { + // 37 is the fixed tail exactly: 3 for the mark, 6 for our port, 7 for the + // state, 10 for rx and 11 for tx. Counted, not estimated - the endpoint + // list had this written as 42 when it was 44 and quietly clipped the rate. + ((w - 1).saturating_sub(37)).clamp(14, 38) +} + +fn file_path_w(w: usize) -> usize { + ((w - 1).saturating_sub(30)).max(18) +} + +/// The column names above `endpoint_rows`. The leading three cells are the +/// selection mark's column, left blank here. +fn endpoint_head(w: usize, p: &Palette) -> String { + tc::seg( + &[( + p.dim.as_str(), + format!( + " {}{:<9}{:>10}{:>11}{:>11}", + tc::pad("HOST", endpoint_host_w(w)), + "PORTS", + "RX", + "TX", + "RATE" + ), + )], + w - 1, + ) +} + +/// The column names above `connection_rows`. +fn connection_head(w: usize, p: &Palette) -> String { + tc::seg( + &[( + p.dim.as_str(), + format!( + " {}{:<6}{:<7}{:>10}{:>11}", + tc::pad("SOCKET", connection_host_w(w)), + "LOCAL", + "STATE", + "RX", + "TX" + ), + )], + w - 1, + ) +} + +/// The column names above `file_rows`. +fn file_head(w: usize, p: &Palette) -> String { + tc::seg( + &[( + p.dim.as_str(), + format!( + " {}{:>10}{:>12}", + tc::pad("PATH", file_path_w(w)), + "SIZE", + "GROWTH" + ), + )], + w - 1, + ) +} + +/// Remote hosts, ranked by what they have carried since launch. +fn endpoint_rows( + spots: &[Spot], + at: usize, + focused: bool, + room: usize, + w: usize, + names: &Resolver, + p: &Palette, +) -> Vec<String> { + let host_w = endpoint_host_w(w); + spots + .iter() + .take(room.max(1)) + .enumerate() + .map(|(i, spot)| { + let here = focused && i == at; + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let name = { + let found = names.name(&spot.peer); + if found.is_empty() { spot.peer.clone() } else { found } + }; + let ports: Vec<String> = spot + .ports + .iter() + .take(2) + .map(|port| { + let named = service(*port); + if named.is_empty() { port.to_string() } else { named } + }) + .collect(); + let moving = spot.down_rate + spot.up_rate; + let c = |colour: &str| format!("{}{}", tint, colour); + tc::seg( + &[ + ( + &c(if here { &p.accent } else { &p.dim }), + if here { " ▸ ".into() } else { " ".into() }, + ), + ( + &c(if spot.alive { &p.txt } else { &p.dim }), + tc::pad(&name.chars().take(host_w - 1).collect::<String>(), host_w), + ), + ( + &c(&p.dim), + format!("{:<9}", ports.join("/").chars().take(9).collect::<String>()), + ), + (&c(&p.down), format!("↓{:>9}", units(spot.down as f64))), + (&c(&p.up), format!(" ↑{:>9}", units(spot.up as f64))), + ( + &c(if moving > 0.0 { &p.ok } else { &p.dim }), + format!("{:>11}", rate(moving)), + ), + (&tint, if here { " ".repeat(w) } else { String::new() }), + ], + w - 1, + ) + }) + .collect() +} + +/// The sockets open right now, which is a different list from the hosts. +fn connection_rows( + conns: &[Conn], + at: usize, + focused: bool, + room: usize, + w: usize, + p: &Palette, +) -> Vec<String> { + let host_w = connection_host_w(w); + conns + .iter() + .take(room.max(1)) + .enumerate() + .map(|(i, conn)| { + let here = focused && i == at; + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let where_ = format!("{}:{}", conn.peer, conn.port); + let c = |colour: &str| format!("{}{}", tint, colour); + tc::seg( + &[ + ( + &c(if here { &p.accent } else { &p.dim }), + if here { " ▸ ".into() } else { " ".into() }, + ), + ( + &c(if conn.alive { &p.txt } else { &p.dim }), + tc::pad( + &where_.chars().take(host_w - 1).collect::<String>(), + host_w, + ), + ), + // The local port, which ss and netstat both call Local. + // Without it five rows to one CDN are five identical + // lines, and "why are there so many of these" has no + // answer on screen. + ( + &c(&p.dim), + format!( + "{:<6}", + if conn.mine > 0 { conn.mine.to_string() } else { "-".into() } + ), + ), + ( + &c(if conn.alive { &p.ok } else { &p.dim }), + format!("{:<7}", if conn.alive { "open" } else { "closed" }), + ), + (&c(&p.down), format!("↓{:>9}", units(conn.down as f64))), + (&c(&p.up), format!(" ↑{:>9}", units(conn.up as f64))), + (&tint, if here { " ".repeat(w) } else { String::new() }), + ], + w - 1, + ) + }) + .collect() +} + +/// Open files, with how fast each is growing since this screen opened. +fn file_rows( + files: &[OpenFile], + sizes: &HashMap<String, (u64, f64)>, + at: usize, + focused: bool, + room: usize, + w: usize, + p: &Palette, +) -> Vec<String> { + let path_w = file_path_w(w); + files + .iter() + .take(room.max(1)) + .enumerate() + .map(|(i, item)| { + let here = focused && i == at; + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let (grew, span) = match sizes.get(&item.path) { + Some((was, when)) => (item.size.saturating_sub(*was), now() - when), + None => (0, 0.0), + }; + let growth = if grew > 0 && span > 0.0 { + format!("+{}", rate(grew as f64 / span)) + } else { + String::new() + }; + let c = |colour: &str| format!("{}{}", tint, colour); + tc::seg( + &[ + ( + &c(if here { &p.accent } else { &p.dim }), + if here { " ▸ ".into() } else { " ".into() }, + ), + (&c(&p.txt), tc::pad(&short(&item.path, path_w), path_w)), + (&c(&p.dim), format!("{:>10}", units(item.size as f64))), + ( + &c(if grew > 0 { &p.ok } else { &p.dim }), + format!("{:>12}", growth), + ), + (&tint, if here { " ".repeat(w) } else { String::new() }), + ], + w - 1, + ) + }) + .collect() +} + +/// One process in full: what it is, who it talks to, what it writes. +#[allow(clippy::too_many_arguments)] +fn detail_rows( + row: &Proc, + spots: &[Spot], + conns: &[Conn], + files: &[OpenFile], + sizes: &HashMap<String, (u64, f64)>, + focus: Option<usize>, + at: &[usize; 3], + w: usize, + h: usize, + interval: f64, + names: &Resolver, + p: &Palette, +) -> (Vec<String>, Option<(usize, usize)>) { + // Where the cursor ended up in the body, and how many rows it owns - its + // own row plus the chart drawn under it. The caller cannot work this out: + // the body is built at whatever height it needs and the selected row's + // position depends on how many rows every section above it drew. + let mut cursor: Option<(usize, usize)> = None; + let facts = process_facts(row.pid); + let here_now = running(row.pid); + let total = (row.up + row.down) as f64; + let mut out = vec![tc::title( + &format!("{} · pid {}", row.name, row.pid), + w, + &p.accent, + )]; + out.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {}", units(total))), + (p.dim.as_str(), " since first seen · ".into()), + (p.down.as_str(), format!("↓ {}", rate(row.down_rate))), + (p.dim.as_str(), " ".into()), + (p.up.as_str(), format!("↑ {}", rate(row.up_rate))), + ], + w - 1, + )); + if row.pid == 0 { + out.push(tc::seg( + &[( + p.dim.as_str(), + " another user's process - named from its control group, \ + since /proc is closed to us" + .into(), + )], + w - 1, + )); + } else if !here_now { + out.push(tc::seg( + &[( + p.warn.as_str(), + " this process has exited - its total is kept, and nothing \ + below is live" + .into(), + )], + w - 1, + )); + } else if !row.alive { + out.push(tc::seg( + &[( + p.dim.as_str(), + " no connection open at the moment - what is below is the \ + last that was seen" + .into(), + )], + w - 1, + )); + } + out.push(String::new()); + + // This process's own traffic, on the same chart as the machine's. + let spare = h.saturating_sub(out.len()); + let graph_h = if spare >= 30 { + 7 + } else if spare >= 24 { + 5 + } else { + 0 + }; + if graph_h > 0 && !row.hist.is_empty() { + out.push(chart_head(row.hist.len(), w, "THIS PROCESS", interval, p)); + out.extend(chart(&row.hist, w, graph_h, p)); + out.push(String::new()); + } + + if h.saturating_sub(out.len()) >= 12 { + out.push(tc::seg(&[(p.lbl.as_str(), " ── PROCESS ── ".into())], w - 1)); + let cmd = if facts.cmdline.is_empty() { "?" } else { &facts.cmdline }; + out.extend(field_rows("command", cmd, w, &p.txt, p)); + if !facts.cwd.is_empty() { + let cwd = short(&facts.cwd, w.saturating_sub(14)); + out.extend(field_rows("directory", &cwd, w, &p.txt, p)); + } + out.push(String::new()); + } + + // Every list is drawn in full. The three used to share what was left, + // with the focused one taking the room and the other two collapsing to + // a single line - so a process with six sockets showed one of them and + // said "6 sockets" above it, and the only way to see the rest was to + // remember which key focused that section. The screen is now as tall as + // it needs to be and the caller scrolls it. + let counts = [spots.len(), conns.len(), files.len()]; + let shares = [counts[0].max(1), counts[1].max(1), counts[2].max(1)]; + + for (which, (name, note)) in [ + ("TALKING TO", "endpoint"), + ("CONNECTIONS", "socket"), + ("FILES", "file"), + ] + .into_iter() + .enumerate() + { + let focused = focus == Some(which); + let next = tc::next_section(focus, &counts) == Some(which); + out.push(section_head(name, counts[which], note, focused, next, w, p)); + let room = shares[which]; + if counts[which] == 0 { + out.push(tc::seg(&[(p.dim.as_str(), " none".into())], w - 1)); + } else if which == 0 { + out.push(endpoint_head(w, p)); + let mut rows = endpoint_rows(spots, at[which], focused, room, w, names, p); + // The chart belongs under the line it describes, not after the + // list: with it at the bottom you had to hold which host was + // highlighted in your head while your eye travelled. Here the + // answer is where the question is. + let pick_at = at[which].min(spots.len() - 1); + if focused { + let pick = &spots[pick_at]; + let mut tall = 1; + if !pick.hist.is_empty() && pick_at < rows.len() { + let mut under = chart(&pick.hist, w, 4, p); + under.push(String::new()); + tall += under.len(); + rows.splice(pick_at + 1..pick_at + 1, under); + } + cursor = Some((out.len() + pick_at, tall)); + } + out.extend(rows); + } else if which == 1 { + out.push(connection_head(w, p)); + let mut rows = connection_rows(conns, at[which], focused, room, w, p); + let pick_at = at[which].min(conns.len().saturating_sub(1)); + if focused { + let mut tall = 1; + if let Some(pick) = conns.get(pick_at) { + if !pick.hist.is_empty() && pick_at < rows.len() { + let mut under = chart(&pick.hist, w, 4, p); + under.push(String::new()); + tall += under.len(); + rows.splice(pick_at + 1..pick_at + 1, under); + } + } + cursor = Some((out.len() + pick_at, tall)); + } + out.extend(rows); + } else { + out.push(file_head(w, p)); + if focused { + cursor = Some((out.len() + at[which].min(files.len() - 1), 1)); + } + out.extend(file_rows(files, sizes, at[which], focused, room, w, p)); + } + out.push(String::new()); + } + + let io = if here_now { + proc_io(row.pid) + } else { + HashMap::new() + }; + if !io.is_empty() && h.saturating_sub(out.len()) >= 2 { + out.push(tc::seg( + &[ + (p.lbl.as_str(), " ── DISK ── ".into()), + ( + p.dim.as_str(), + format!( + "read {} · written {} since it started", + units(*io.get("read_bytes").unwrap_or(&0) as f64), + units(*io.get("write_bytes").unwrap_or(&0) as f64) + ), + ), + ], + w - 1, + )); + // The same chart the network half uses, on the same axes rule: + // written above the line, read below. Two lifetime totals say + // whether it has ever touched the disk; this says whether it is + // touching it now, which is the question the rest of this screen + // is answering about the network. + let room = h.saturating_sub(out.len() + 1); + let disk_h = if room >= 7 { room.min(9) } else { 0 }; + // Drawn whether or not it is moving, like the network chart above: + // a flat line at zero says "not touching the disk", which is an + // answer. Hiding it would leave the reader unsure whether the + // process is quiet or the chart is broken. + if disk_h > 0 && !row.disk.is_empty() { + out.extend(chart(&row.disk, w, disk_h, p)); + } + } + (out, cursor) +} + +/// One block per interval, for a log or a pipe. +fn plain_line(rows: &[Proc], started: f64, live: bool, limit: usize) -> String { + let mut lines = vec![format!( + "--- {} elapsed · sorted by {} ---", + elapsed(now() - started), + if live { "live" } else { "total" } + )]; + for row in rows.iter().take(limit) { + lines.push(format!( + "{:<22} {:<8} {:>11} {:>11} {:>11} {:>11}", + row.name, + if row.pid > 0 { + row.pid.to_string() + } else { + "-".to_string() + }, + units((row.up + row.down) as f64), + rate(row.up_rate + row.down_rate), + rate(row.down_rate), + rate(row.up_rate) + )); + } + lines.join("\n") +} + +/// The table's order, which is also the order Enter indexes into. +fn ordered(state: &Arc<Mutex<State>>, mine: bool, live: bool) -> Vec<Proc> { + let mut rows: Vec<Proc> = match state.lock() { + Ok(guard) => guard + .totals + .values() + .filter(|r| !mine || r.pid != 0) + .cloned() + .collect(), + Err(_) => return Vec::new(), + }; + if live { + rows.sort_by(|a, b| { + (b.up_rate + b.down_rate) + .total_cmp(&(a.up_rate + a.down_rate)) + .then((b.up + b.down).cmp(&(a.up + a.down))) + }); + } else { + rows.sort_by(|a, b| { + (b.up + b.down) + .cmp(&(a.up + a.down)) + .then((b.up_rate + b.down_rate).total_cmp(&(a.up_rate + a.down_rate))) + }); + } + rows +} + +fn main() { + tc::maybe_help(include_str!("netwatch_help.txt")); + // Config first, argv second: `--interval 2` beats a config saying 1, + // which is the precedence netwatch.py uses. These five were documented + // in config.example.json and read by nobody. + let cfg = tc::load_config("netwatch"); + let mut interval = tc::poll_secs(tc::cfg_f64(&cfg, "interval", 1.0), 1.0).max(0.2); + let mut limit = tc::cfg_usize(&cfg, "limit", 0); + let mut external = cfg + .get("external") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let mut mine = cfg.get("mine").and_then(|v| v.as_bool()).unwrap_or(true); + let mut sort_live = tc::cfg_str(&cfg, "sort", "total") == "live"; + let mut plain = false; + let args: Vec<String> = std::env::args().skip(1).collect(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-i" | "--interval" if i + 1 < args.len() => { + interval = tc::poll_secs(args[i + 1].parse().unwrap_or(1.0), 1.0).max(0.2); + i += 2; + } + "-n" | "--limit" if i + 1 < args.len() => { + limit = args[i + 1].parse().unwrap_or(0); + i += 2; + } + "--sort" if i + 1 < args.len() => { + sort_live = args[i + 1] == "live"; + i += 2; + } + "--external" => { + external = true; + i += 1; + } + "--all-external" => { + external = false; + i += 1; + } + "--all-users" => { + mine = false; + i += 1; + } + "--plain" => { + plain = true; + i += 1; + } + // Refused rather than ignored: a typo that silently does + // nothing is worse than one that says so, and the Python has + // always said so. + other => { + eprintln!("unknown option {:?} - try --help", other); + std::process::exit(2); + } + } + } + + let p = palette(); + let state = Arc::new(Mutex::new(State { + started: now(), + ..Default::default() + })); + let poller = Arc::clone(&state); + std::thread::spawn(move || loop { + // A poller that dies takes its explanation with it, and an empty + // table looks exactly like a machine with no sockets on it. + { + let mut guard = match poller.lock() { + Ok(g) => g, + Err(_) => return, + }; + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + sample(&mut guard, external); + })) + .is_err() + { + guard.err = "poller stopped - see the pane it was started from".into(); + return; + } + } + std::thread::sleep(Duration::from_secs_f64(interval)); + }); + + // One block per interval, for a log or a pipe. Nothing here touches + // the screen, so it can be redirected to a file and left running. + if plain { + loop { + std::thread::sleep(Duration::from_secs_f64(interval)); + let (started, err) = { + let guard = match state.lock() { + Ok(g) => g, + Err(_) => return, + }; + (guard.started, guard.err.clone()) + }; + if !err.is_empty() { + eprintln!("{}", err); + } + let rows = ordered(&state, mine, sort_live); + let show = if limit > 0 { limit } else { rows.len() }; + println!("{}", plain_line(&rows, started, sort_live, show)); + use std::io::Write; + let _ = std::io::stdout().flush(); + } + } + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let mut selected = 0usize; + // The second screen: which process, which of its three lists is taking + // the keys, and where each list is scrolled to. + let mut detail: Option<(i32, String)> = None; + // Which section has the cursor, if any. Opens with none: the screen is + // a thing to read before it is a thing to work, and a cursor sitting + // somewhere you did not put it is a question rather than an answer. + let mut focus: Option<usize> = None; + let mut at = [0usize; 3]; + // How far down the detail screen we are. Clamped against the body when + // the frame is drawn, because the body's height depends on how many + // sockets and files the process has right now. + let mut dscroll = 0usize; + // How long each section was when it was last drawn. The keys are read + // before the frame is built, so walking off the end of a list has to be + // judged against the length it had a moment ago - which is the same + // length the reader is looking at. + let mut section_len = [0usize; 3]; + // What each open file measured when this screen opened, so the growth + // column is over the time you have been looking rather than the life + // of the file. + let mut sizes: HashMap<String, (u64, f64)> = HashMap::new(); + let mut notice: Option<(String, String, f64)> = None; + // What `c` would copy: known while drawing, wanted when the key is hit. + let mut pending_copy = String::new(); + let names = Resolver::new(); + + loop { + for key in keyboard.poll() { + if detail.is_some() { + match key.as_str() { + // Left and esc come out; q quits, which is what the + // footer beside it says and what q does everywhere + // else. backspace is gone: an alias no hint named. + "esc" | "left" => { + detail = None; + sizes.clear(); + } + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "r" | "R" => { + if let Ok(mut guard) = state.lock() { + guard.totals.clear(); + guard.conns.clear(); + guard.spots.clear(); + guard.series.clear(); + guard.started = now(); + } + detail = None; + sizes.clear(); + } + // Focused, up and down walk the three lists as though + // they were one: off the bottom of a section is the top + // of the next, off the top is the bottom of the one + // above. Only the two far ends let go. Unfocused, they + // move the screen. Whichever is in front of you is what + // they act on, which is the rule the list screen follows. + "up" | "k" | "K" => match focus { + Some(here) => { + focus = tc::step_across_sections(here, at[here], §ion_len, false) + .map(|(sect, row)| { + at[sect] = row; + sect + }); + } + None => dscroll = dscroll.saturating_sub(1), + }, + "down" | "j" | "J" => match focus { + Some(here) => { + focus = tc::step_across_sections(here, at[here], §ion_len, true) + .map(|(sect, row)| { + at[sect] = row; + sect + }); + } + None => dscroll = dscroll.saturating_add(1), + }, + // The pane height is read here rather than carried, + // because a page is only meaningful against the pane as + // it is now and it may have been resized since. + "pgup" => { + let page = tc::size().1.saturating_sub(4).max(1); + dscroll = dscroll.saturating_sub(page); + } + "pgdn" => { + let page = tc::size().1.saturating_sub(4).max(1); + dscroll = dscroll.saturating_add(page); + } + "home" => dscroll = 0, + "end" => dscroll = usize::MAX, + // tab is the only way between sections. e and f jumped + // straight to two of the three, which meant three keys + // for one job and a section heading advertising a letter + // of its own - and no key at all for the middle one. + // Where it goes, and which sections it steps over, is + // toys-core's rule rather than this widget's. + "tab" => focus = tc::next_section(focus, §ion_len), + "c" | "C" => { + if !pending_copy.is_empty() { + // The value goes in the message either way: OSC + // 52 is refused by some terminals and swallowed + // by some multiplexers, and a copy that quietly + // did nothing would leave nothing to read. + let copied = tc::clipboard(&pending_copy); + notice = Some(( + format!( + "{}{}", + if copied { "copied " } else { "no clipboard " }, + pending_copy + ), + if copied { p.ok.clone() } else { p.warn.clone() }, + now() + 8.0, + )); + } + } + _ => {} + } + continue; + } + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "1" => sort_live = false, + "2" => sort_live = true, + "s" | "S" | "t" | "T" => sort_live = !sort_live, + "o" | "O" => { + mine = !mine; + selected = 0; + } + "up" | "k" | "K" => selected = selected.saturating_sub(1), + "down" | "j" | "J" => selected += 1, + "enter" | "right" => { + if let Some(pick) = ordered(&state, mine, sort_live).get(selected) { + detail = Some((pick.pid, pick.name.clone())); + focus = None; + at = [0; 3]; + dscroll = 0; + sizes.clear(); + } + } + "r" | "R" => { + if let Ok(mut guard) = state.lock() { + guard.totals.clear(); + guard.conns.clear(); + guard.spots.clear(); + guard.series.clear(); + guard.started = now(); + } + selected = 0; + } + _ => {} + } + } + + let (w, h) = tc::size(); + if notice.as_ref().is_some_and(|n| now() >= n.2) { + notice = None; + } + + // One process in full. Its row is looked up fresh each frame so the + // figures keep moving while the screen is open, and it survives the + // process exiting - which is often when it is being looked at. + if let Some((pid, name)) = detail.clone() { + let (row, spots, conns) = { + let guard = match state.lock() { + Ok(g) => g, + Err(_) => return, + }; + let row = guard.totals.get(&(pid, name.clone())).cloned(); + let mut spots: Vec<Spot> = guard + .spots + .values() + .filter(|s| s.pid == pid && s.name == name) + .cloned() + .collect(); + spots.sort_by(|a, b| { + (b.up + b.down) + .cmp(&(a.up + a.down)) + .then(a.peer.cmp(&b.peer)) + }); + let mut conns: Vec<Conn> = guard + .conns + .values() + .filter(|c| c.pid == pid && c.name == name) + .cloned() + .collect(); + conns.sort_by(|a, b| { + (b.up + b.down) + .cmp(&(a.up + a.down)) + .then(a.peer.cmp(&b.peer)) + }); + (row, spots, conns) + }; + let row = match row { + Some(r) => r, + None => { + detail = None; + continue; + } + }; + let files = if running(pid) { open_files(pid) } else { Vec::new() }; + for file in &files { + sizes.entry(file.path.clone()).or_insert((file.size, now())); + } + let counts = [spots.len(), conns.len(), files.len()]; + section_len = counts; + if let Some(at_focus) = focus { + at[at_focus] = at[at_focus].min(counts[at_focus].saturating_sub(1)); + } + // The selection is known here and the key is pressed elsewhere, + // so what `c` would copy is recorded while the frame is drawn. + // Nothing focused means nothing selected, so c has nothing to + // take. It says so rather than copying whatever happened to be + // first. + pending_copy = match focus { + Some(0) => spots.get(at[0]).map(|s| s.peer.clone()).unwrap_or_default(), + Some(1) => conns + .get(at[1]) + .map(|c| format!("{}:{}", c.peer, c.port)) + .unwrap_or_default(), + Some(_) => files.get(at[2]).map(|f| f.path.clone()).unwrap_or_default(), + None => String::new(), + }; + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![ + (p.accent.as_str(), "↑↓".into()), + ( + p.dim.as_str(), + if focus.is_some() { " select" } else { " scroll" }.to_string(), + ), + ], + vec![ + (p.accent.as_str(), "tab".into()), + ( + p.dim.as_str(), + if focus.is_some() { " next section" } else { " into a section" }.to_string(), + ), + ], + vec![(p.dim.as_str(), "[c]opy".into())], + vec![(p.dim.as_str(), "[r]ezero".into())], + vec![ + (p.accent.as_str(), "←".into()), + (p.dim.as_str(), "/esc back".into()), + ], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + let mut foot: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + if let Some((text, colour, _)) = notice.as_ref() { + foot = vec![tc::seg(&[(colour.as_str(), format!(" {}", text))], w - 1)]; + } + let room = h.saturating_sub(foot.len() + 1).max(1); + // Built at the height it wants rather than the height it has: + // the lists are drawn in full and the charts get their rows, and + // what does not fit is scrolled to rather than dropped. + let natural = room + spots.len() + conns.len() + files.len() + 24; + let (body, cursor) = detail_rows( + &row, &spots, &conns, &files, &sizes, focus, &at, w, natural, interval, &names, &p, + ); + let furthest = body.len().saturating_sub(room); + // The screen follows the cursor into a section. It did not + // before: the body is built at whatever height it needs and + // scrolled to, but nothing tied the scroll to the selection, so + // moving the cursor down a long list walked it off the bottom of + // the pane - with its chart, which is the thing you moved the + // cursor to see. On a short pane one press was enough. + // + // The chart is why this reveals a span rather than a row: pulling + // the selected line just into view would leave the four rows it + // was drawn for still below the edge. + if let Some((at_row, tall)) = cursor { + if at_row < dscroll { + dscroll = at_row; + } else if at_row + tall > dscroll + room { + dscroll = (at_row + tall).saturating_sub(room); + } + } + dscroll = dscroll.min(furthest); + let last = (dscroll + room).min(body.len()); + let mut shown: Vec<String> = body[dscroll..last].to_vec(); + while shown.len() < room { + shown.push(String::new()); + } + // Appending this to an already-packed line overflowed the width + // and the terminal wrapped it, so the footer's last row was the + // tail of a number. It is a hint like the others now, packed with + // them. + // + // `room` above reserved one line for it, and one is enough: + // pack_hints is greedy, so the hints before this one pack the + // same way whether it is there or not, and it either joins the + // last line or starts one more. Never two more - which matters, + // because `last` was measured against `room` and a second extra + // line would cost a body row the label had already counted. + if furthest > 0 { + let mut with_pos = hints.clone(); + with_pos.push(vec![( + p.dim.as_str(), + scroll_label(dscroll + 1, last, body.len()), + )]); + foot = tc::pack_hints(&with_pos, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + if let Some((text, colour, _)) = notice.as_ref() { + foot = vec![tc::seg(&[(colour.as_str(), format!(" {}", text))], w - 1)]; + } + while shown.len() + foot.len() < h { + shown.push(String::new()); + } + } + shown.extend(foot); + tc::draw(&shown, w, h); + std::thread::sleep(Duration::from_millis(300)); + continue; + } + + let rows = ordered(&state, mine, sort_live); + let guard = match state.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !rows.is_empty() && selected >= rows.len() { + selected = rows.len() - 1; + } + let moving = rows.iter().filter(|r| r.up_rate + r.down_rate > 0.0).count(); + let down: f64 = rows.iter().map(|r| r.down_rate).sum(); + let up: f64 = rows.iter().map(|r| r.up_rate).sum(); + + let mut out = vec![tc::title("netwatch", w, &p.accent)]; + // Held open. What this line has to say depends on how many rows fit, + // which is not known until the chart above the table has been built - + // but the line is one row tall whatever it ends up saying, so nothing + // below it moves. + let count_at = out.len(); + out.push(String::new()); + out.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} moving", moving)), + (p.dim.as_str(), " · ".into()), + (p.accent.as_str(), elapsed(now() - guard.started)), + (p.dim.as_str(), " · sorted by ".into()), + ( + p.accent.as_str(), + if sort_live { "live".into() } else { "total".into() }, + ), + ( + p.dim.as_str(), + format!(" every {}s · rates over {}s", interval, RATE_WINDOW as i64), + ), + ], + w - 1, + )); + out.push(tc::seg( + &[ + ( + p.dim.as_str(), + if mine { + " TCP only · ".into() + } else { + " TCP only · every user · ".into() + }, + ), + (p.down.as_str(), format!("↓ {}", rate(down))), + (p.dim.as_str(), " ".into()), + (p.up.as_str(), format!("↑ {}", rate(up))), + ( + p.dim.as_str(), + if external { + " · internet only".into() + } else { + " · everything off-box".into() + }, + ), + ], + w - 1, + )); + + // What the interfaces actually moved, against what the sockets can + // explain. On a router the two differ by most of the traffic. + let (wire_rx, wire_tx) = guard.wire_rate; + let wire = wire_rx + wire_tx; + if wire > 0.0 { + let share = ((down + up) / wire).min(1.0); + let mut said = vec![ + (p.lbl.as_str(), " interfaces".to_string()), + (p.dim.as_str(), " · ".into()), + (p.down.as_str(), format!("↓ {}", rate(wire_rx))), + (p.dim.as_str(), " ".into()), + (p.up.as_str(), format!("↑ {}", rate(wire_tx))), + (p.dim.as_str(), " · ".into()), + ( + if share >= 0.9 { &p.dim } else { &p.warn }, + format!("{:.0}% of it has a socket", share * 100.0), + ), + ]; + let which = wire_label(&guard.wire_names); + let used: usize = said.iter().map(|(_, t)| t.chars().count()).sum(); + if !which.is_empty() && used + which.chars().count() + 4 <= w - 1 { + said.push((p.grid.as_str(), format!(" · {}", which))); + } + out.push(tc::seg(&said, w - 1)); + } + if !guard.err.is_empty() { + out.push(tc::seg(&[(p.bad.as_str(), format!(" ! {}", guard.err))], w - 1)); + } + out.push(String::new()); + + // The chart takes a share of the pane and the list takes the rest, + // but the list is the point: below a certain height there is no + // chart at all rather than two rows of neither. + let spare = h.saturating_sub(out.len() + 4); + let graph_h = if spare >= 20 { + 9 + } else if spare >= 15 { + 7 + } else if spare >= 11 { + 5 + } else { + 0 + }; + let series: Vec<(f64, f64)> = guard + .series + .iter() + .map(|s| if mine { (s.0, s.1) } else { (s.2, s.3) }) + .collect(); + if graph_h > 0 && !series.is_empty() { + out.push(tc::seg( + &[ + (p.lbl.as_str(), " ── PROCESS WATCH ── ".into()), + (p.up.as_str(), "↑ tx above".into()), + (p.dim.as_str(), " · ".into()), + (p.down.as_str(), "↓ rx below".into()), + ( + p.dim.as_str(), + format!(" · {} of history", elapsed(series.len() as f64 * interval)), + ), + ], + w - 1, + )); + out.extend(chart(&series, w, graph_h, &p)); + out.push(String::new()); + } + + let room = h.saturating_sub(out.len() + 3).max(1); + let show = if limit > 0 { limit.min(room) } else { room }; + // The window follows the cursor, as it does in the detail screen and + // in github's account list. It did not before: the table always drew + // rows 0..show while the cursor was free to walk to the end of the + // list, so on any pane too short for every process the cursor left + // the screen and there was nothing to say where it had gone - and + // enter still opened whatever it was sitting on, unseen. + let first = if rows.len() > show { + selected.saturating_sub(show / 2).min(rows.len() - show) + } else { + 0 + }; + // And the count says so. "27 processes" above a table of 15 is a + // partial result presented as a total, which is the one thing a + // number here must never be. + let last = (first + show).min(rows.len()); + out[count_at] = tc::seg( + &[ + ( + p.dim.as_str(), + format!( + " {} process{}", + rows.len(), + if rows.len() == 1 { "" } else { "es" } + ), + ), + ( + if rows.len() > show { p.accent.as_str() } else { p.dim.as_str() }, + if rows.len() > show { + format!(" · showing {}-{}", first + 1, last) + } else { + String::new() + }, + ), + ], + w - 1, + ); + if rows.is_empty() { + out.push(tc::seg( + &[( + p.dim.as_str(), + " Nothing has moved yet. Totals start at zero, so this fills as traffic happens.".into(), + )], + w - 1, + )); + } else { + out.extend(table(&rows, w, first, show, selected, &p)); + } + + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " select".into())], + vec![ + (p.accent.as_str(), "→/↵".into()), + (p.dim.as_str(), " details".into()), + ], + vec![( + if sort_live { &p.dim } else { &p.accent }, + "[1] total".into(), + )], + vec![( + if sort_live { &p.accent } else { &p.dim }, + "[2] live".into(), + )], + vec![( + p.dim.as_str(), + format!("[o]{} others", if mine { "show" } else { "hide" }), + )], + vec![(p.dim.as_str(), "[r]ezero".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + drop(guard); + let foot: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + while out.len() < h.saturating_sub(foot.len()) { + out.push(String::new()); + } + out.extend(foot); + tc::draw(&out, w, h); + std::thread::sleep(Duration::from_millis(300).min(Duration::from_secs_f64(interval))); + } +} + +/// Sent above the line, received below it, newest on the right. +/// +/// That way round because of the arrows: ↑ means upload and ↓ means +/// download, so upload has to be the half that goes up. +fn chart(series: &[(f64, f64)], w: usize, h: usize, p: &Palette) -> Vec<String> { + let canvas = h.saturating_sub(3).max(2); + let up_h = (canvas / 2).max(1); + let down_h = canvas.saturating_sub(up_h).max(1); + let plot = w.saturating_sub(18).max(12); + let window: Vec<(f64, f64)> = series.iter().rev().take(plot * 2).rev().copied().collect(); + let rx: Vec<f64> = window.iter().map(|s| s.0).collect(); + let tx: Vec<f64> = window.iter().map(|s| s.1).collect(); + let rx_peak = rx.iter().cloned().fold(0.0f64, f64::max).max(1.0); + let tx_peak = tx.iter().cloned().fold(0.0f64, f64::max).max(1.0); + // Each label carries its own direction and neither is signed: rx is not + // negative traffic, it is simply the half drawn downward. + let up_label = format!("↑ {}", rate(tx_peak)); + let down_label = format!("↓ {}", rate(rx_peak)); + let lab = up_label + .chars() + .count() + .max(down_label.chars().count()) + .clamp(9, 16); + let plot = w.saturating_sub(lab + 4).max(12); + + let mut out = Vec::new(); + out.push(tc::seg( + &[ + (p.up.as_str(), format!("{:>1$} ", up_label, lab)), + (p.grid.as_str(), format!("┌{}┐", "─".repeat(plot))), + ], + w - 1, + )); + for masks in braille_canvas(&tx, tx_peak, plot, up_h, false) { + let mut line = vec![ + (p.dim.clone(), " ".repeat(lab + 1)), + (p.grid.clone(), "│".into()), + ]; + line.extend(braille_row(&masks, &p.up)); + line.push((p.grid.clone(), "│".into())); + let refs: Vec<(&str, String)> = line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + out.push(tc::seg(&refs, w - 1)); + } + out.push(tc::seg( + &[ + (p.dim.as_str(), format!("{:>1$} ", "0", lab)), + (p.grid.as_str(), format!("├{}┤", "─".repeat(plot))), + ], + w - 1, + )); + for masks in braille_canvas(&rx, rx_peak, plot, down_h, true) { + let mut line = vec![ + (p.dim.clone(), " ".repeat(lab + 1)), + (p.grid.clone(), "│".into()), + ]; + line.extend(braille_row(&masks, &p.down)); + line.push((p.grid.clone(), "│".into())); + let refs: Vec<(&str, String)> = line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + out.push(tc::seg(&refs, w - 1)); + } + out.push(tc::seg( + &[ + (p.down.as_str(), format!("{:>1$} ", down_label, lab)), + (p.grid.as_str(), format!("└{}┘", "─".repeat(plot))), + ], + w - 1, + )); + out +} + +/// The process table, dropping columns rather than clipping them. +fn table( + rows: &[Proc], + w: usize, + first: usize, + limit: usize, + selected: usize, + p: &Palette, +) -> Vec<String> { + let avail = (w - 1).saturating_sub(2 + 8 + 11); + let wide = avail >= 10 + 11 + 22; + let mid = avail >= 10 + 11; + let name_w = avail + .saturating_sub(if wide { 33 } else if mid { 11 } else { 0 }) + .clamp(8, 26); + + let mut head = vec![ + (p.dim.as_str(), format!(" {}", tc::pad("PROCESS", name_w))), + (p.dim.as_str(), format!("{:<8}", "PID")), + (p.dim.as_str(), format!("{:>11}", "TOTAL")), + ]; + if mid { + head.push((p.dim.as_str(), format!("{:>11}", "NOW"))); + } + if wide { + head.push((p.dim.as_str(), format!("{:>11}", "DOWN"))); + head.push((p.dim.as_str(), format!("{:>11}", "UP"))); + } + let mut out = vec![tc::seg(&head, w - 1)]; + + for (i, row) in rows.iter().skip(first).take(limit).enumerate() { + let live = row.up_rate + row.down_rate; + let total = (row.up + row.down) as f64; + let here = first + i == selected; + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let name_c = format!( + "{}{}", + tint, + if here { + &p.accent + } else if row.alive { + &p.txt + } else { + &p.dim + } + ); + let pid_c = format!("{}{}", tint, p.dim); + let total_c = format!("{}{}", tint, if total > 0.0 { &p.txt } else { &p.dim }); + let live_c = format!("{}{}", tint, if live > 0.0 { &p.ok } else { &p.dim }); + let down_c = format!("{}{}", tint, if row.down_rate > 0.0 { &p.down } else { &p.dim }); + let up_c = format!("{}{}", tint, if row.up_rate > 0.0 { &p.up } else { &p.dim }); + let name: String = row.name.chars().take(name_w.saturating_sub(2)).collect(); + let mut line = vec![ + ( + name_c.as_str(), + format!("{} {}", if here { "▸" } else { " " }, tc::pad(&name, name_w - 1)), + ), + ( + pid_c.as_str(), + format!("{:<8}", if row.pid > 0 { row.pid.to_string() } else { "-".into() }), + ), + (total_c.as_str(), format!("{:>11}", units(total))), + ]; + if mid { + line.push((live_c.as_str(), format!("{:>11}", rate(live)))); + } + if wide { + line.push((down_c.as_str(), format!("{:>11}", rate(row.down_rate)))); + line.push((up_c.as_str(), format!("{:>11}", rate(row.up_rate)))); + } + if here { + line.push((tint.as_str(), " ".repeat(w))); + } + out.push(tc::seg(&line, w - 1)); + } + out +} + +struct Palette { + ok: String, + warn: String, + bad: String, + dim: String, + grid: String, + txt: String, + lbl: String, + accent: String, + down: String, + up: String, +} + +fn palette() -> Palette { + Palette { + ok: tc::rgb(90, 240, 160), + warn: tc::rgb(255, 200, 90), + bad: tc::rgb(255, 100, 110), + dim: tc::rgb(127, 147, 172), + grid: tc::rgb(60, 78, 98), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + accent: tc::rgb(150, 210, 255), + down: tc::rgb(120, 200, 255), + up: tc::rgb(255, 170, 120), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_failed_ss_read_does_not_reset_the_counter_baselines() { + // One socket, open throughout, whose kernel counters only ever + // climb. `ss` answers, fails, then answers again. + let socket = |sent, recv| { + let mut one = HashMap::new(); + one.insert( + "42".to_string(), + Seen { + sent, + recv, + peer: "192.0.2.1".into(), + port: 443, + mine: 51000, + cgroup: String::new(), + }, + ); + one + }; + let owners = HashMap::new(); + let mut state = State::default(); + + // The first reading is a baseline: a socket already open when we + // started did its megabytes before we were watching. + absorb(&mut state, 1000.0, &socket(1_000_000, 2_000_000), &owners, None, String::new()); + assert_eq!(state.stamp, 1000.0); + assert_eq!(state.last.get("42"), Some(&(1_000_000, 2_000_000))); + + // `ss` wedges or will not run. It must say so and leave the + // baseline alone - both halves of it, the counters and the clock + // they were read at. + absorb(&mut state, 1001.0, &HashMap::new(), &owners, None, "ss would not run".into()); + assert_eq!(state.err, "ss would not run"); + assert_eq!(state.last.get("42"), Some(&(1_000_000, 2_000_000)), "baseline cleared"); + assert_eq!(state.stamp, 1000.0, "clock advanced without the counters it pairs with"); + + // Two seconds after the last good reading, half a kilobyte up and a + // kilobyte down. Before the fix this socket had no baseline left, + // so the whole megabyte of its lifetime counted as traffic in the + // one second since the failed poll. + absorb(&mut state, 1002.0, &socket(1_000_500, 2_001_000), &owners, None, String::new()); + assert!(state.err.is_empty(), "a good reading must clear the error: {}", state.err); + let row = state + .totals + .get(&(0, "(unattributed)".to_string())) + .expect("the socket has a row"); + assert_eq!((row.up, row.down), (500, 1000), "counted a lifetime as one interval"); + // 500 bytes and 1000 bytes over the two seconds since the last + // reading that had numbers in it. + assert!((row.up_rate - 250.0).abs() < 1.0, "{}", row.up_rate); + assert!((row.down_rate - 500.0).abs() < 1.0, "{}", row.down_rate); + } + + /// Colour is not width: `len()` counts escape bytes, so every column + /// check below measures the text alone. + fn bare(line: &str) -> String { + let mut out = String::new(); + let mut chars = line.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + for n in chars.by_ref() { + if n.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out + } + + /// Which cell `token` starts in, counting characters rather than bytes - + /// the arrows are three bytes each and byte offsets would lie. + fn col(line: &str, token: &str) -> usize { + let at = line + .find(token) + .unwrap_or_else(|| panic!("{:?} is not in {:?}", token, line)); + line[..at].chars().count() + } + + fn a_resolver() -> Resolver { + Resolver { + known: Arc::new(Mutex::new(HashMap::new())), + wanted: Arc::new(Mutex::new(Vec::new())), + } + } + + // The headings are found by looking at where the rendered row put its + // columns, not by repeating the arithmetic the row used. A test that + // recomputes the widths would agree with a heading that had slipped. + + /// The window the main list draws, extracted so the test and the widget + /// cannot disagree about it. + fn window_at(selected: usize, show: usize, total: usize) -> usize { + if total > show { selected.saturating_sub(show / 2).min(total - show) } else { 0 } + } + + #[test] + fn the_process_cursor_is_always_on_the_screen() { + // It was not. The table drew rows 0..show whatever the cursor was + // doing, so on any pane too short for the whole list the cursor + // walked off the bottom and vanished - and enter still opened the + // row it was invisibly sitting on. + let p = palette(); + for total in [1usize, 5, 15, 26, 200] { + let rows: Vec<Proc> = (0..total) + .map(|i| Proc { + pid: 1000 + i as i32, + name: format!("proc-{}", i), + down: 4096, + alive: true, + ..Default::default() + }) + .collect(); + for show in [1usize, 3, 7, 15, 40] { + for selected in 0..total { + let first = window_at(selected, show, total); + let drawn = table(&rows, 120, first, show, selected, &p); + let marked = drawn.iter().filter(|l| bare(l).starts_with('▸')).count(); + assert_eq!( + marked, 1, + "total={} show={} selected={}: {} rows carried the cursor", + total, show, selected, marked + ); + // and it is the row it claims to be + let on = drawn + .iter() + .find(|l| bare(l).starts_with('▸')) + .map(|l| bare(l)) + .unwrap_or_default(); + assert!( + on.contains(&format!("proc-{} ", selected)), + "total={} show={} selected={}: cursor sat on {:?}", + total, show, selected, on.trim() + ); + } + } + } + } + + #[test] + fn the_endpoint_heading_sits_over_its_columns() { + let p = palette(); + let names = a_resolver(); + let mut spot = Spot { + peer: "192.0.2.7".into(), + up: 4096, + down: 8192, + alive: true, + ..Default::default() + }; + spot.ports.insert(9999); + for w in [60usize, 84, 120, 200] { + let head = bare(&endpoint_head(w, &p)); + let row = bare(&endpoint_rows(&[spot.clone()], 0, false, 1, w, &names, &p)[0]); + let (wide, down, up) = (row.chars().count(), col(&row, "↓"), col(&row, "↑")); + assert_eq!(head.chars().count(), wide, "heading width at w={}", w); + assert_eq!(col(&head, "HOST"), 3, "host column at w={}", w); + assert_eq!(col(&head, "PORTS"), down - 9, "ports column at w={}", w); + assert_eq!(col(&head, "RX") + 2, down + 10, "rx column at w={}", w); + assert_eq!(col(&head, "TX") + 2, up + 10, "tx column at w={}", w); + assert_eq!(col(&head, "RATE") + 4, wide, "rate column at w={}", w); + } + } + + #[test] + fn the_connection_heading_sits_over_its_columns() { + let p = palette(); + let conn = Conn { + peer: "192.0.2.7".into(), + port: 443, + mine: 44672, + up: 4096, + down: 8192, + alive: true, + ..Default::default() + }; + for w in [66usize, 84, 120, 200] { + let head = bare(&connection_head(w, &p)); + let row = bare(&connection_rows(&[conn.clone()], 0, false, 1, w, &p)[0]); + let (wide, down, up) = (row.chars().count(), col(&row, "↓"), col(&row, "↑")); + assert_eq!(head.chars().count(), wide, "heading width at w={}", w); + assert_eq!(col(&head, "SOCKET"), 3, "socket column at w={}", w); + assert_eq!(col(&head, "STATE"), down - 7, "state column at w={}", w); + // our port sits between the address and the state, and the row + // is searched for the port itself rather than for a width + assert_eq!(col(&head, "LOCAL"), down - 13, "local column at w={}", w); + assert_eq!(col(&row, "44672"), down - 13, "our port at w={}", w); + assert_eq!(col(&head, "RX") + 2, down + 10, "rx column at w={}", w); + assert_eq!(col(&head, "TX") + 2, up + 10, "tx column at w={}", w); + assert_eq!(up + 10, wide, "the tx column is the last at w={}", w); + } + } + + #[test] + fn the_file_heading_sits_over_its_columns() { + let p = palette(); + let files = vec![OpenFile { + path: "/var/log/marker.log".into(), + size: 8192, + }]; + let sizes = HashMap::new(); + let shown = units(8192.0); + for w in [60usize, 84, 120, 200] { + let head = bare(&file_head(w, &p)); + let row = bare(&file_rows(&files, &sizes, 0, false, 1, w, &p)[0]); + let wide = row.chars().count(); + assert_eq!(head.chars().count(), wide, "heading width at w={}", w); + assert_eq!(col(&head, "PATH"), 3, "path column at w={}", w); + assert_eq!( + col(&head, "SIZE") + 4, + col(&row, &shown) + shown.chars().count(), + "size column at w={}", + w + ); + assert_eq!(col(&head, "GROWTH") + 6, wide, "growth column at w={}", w); + } + } + + #[test] + fn a_bursty_process_keeps_a_readable_rate() { + let mut row = Proc::default(); + // A kilobyte at t=0 and nothing for the next three seconds. The + // instantaneous rate is zero for most of that; the windowed one + // stays up, which is the whole point. + fold!(row, 0.0, 1.0, 0, 1000); + fold!(row, 1.0, 1.0, 0, 0); + fold!(row, 2.0, 1.0, 0, 0); + fold!(row, 3.0, 1.0, 0, 0); + assert!(row.down_rate > 0.0, "the rate flickered to nothing"); + // A kilobyte spread over the four seconds the window covers. + assert!((row.down_rate - 250.0).abs() < 1.0, "got {}", row.down_rate); + assert_eq!(row.down, 1000, "the total is unaffected by smoothing"); + } + + #[test] + fn many_sockets_at_one_instant_are_not_a_rate() { + let mut row = Proc::default(); + // One process, fifteen sockets, all folded at the same timestamp - + // which is every sample, since a sample reads them all at once. + // The window spans no time, so there is no rate to compute yet. + for _ in 0..15 { + fold!(row, 0.0, 0.0, 0, 400_000); + } + assert_eq!(row.down, 6_000_000); + assert_eq!( + row.down_rate, 0.0, + "six megabytes in no time at all read as {} B/s", + row.down_rate + ); + // A second poll, a second apart, and the fifteen sockets of the + // first one share the single interval they were read in - they are + // parallel, not fifteen intervals in a row. Six megabytes over the + // one second that poll covered. This asserted six megabytes per + // second across a two-second window before. + let mut row = Proc::default(); + for _ in 0..15 { + fold!(row, 0.0, 1.0, 0, 400_000); + } + fold!(row, 1.0, 1.0, 0, 0); + assert!((row.down_rate - 3_000_000.0).abs() < 1.0, "got {}", row.down_rate); + } + + #[test] + fn a_rate_is_the_window_it_claims() { + let mut row = Proc::default(); + // Two kilobytes a second, steadily, for four seconds. + for i in 0..5 { + fold!(row, i as f64, 1.0, 0, 2000); + } + // Five polls, each covering a second, carrying 2000 bytes each: + // 10000 bytes over the five seconds they cover, so 2000 a second, + // which is what was actually sent. This asserted 2500 before - a + // quarter too high on every row, with the overcount written into + // the comment as though it were the intent. + assert!((row.down_rate - 2000.0).abs() < 1.0, "got {}", row.down_rate); + } + + #[test] + fn history_older_than_the_window_is_dropped() { + let mut row = Proc::default(); + fold!(row, 0.0, 1.0, 0, 5000); + fold!(row, 100.0, 1.0, 0, 1000); + // The ancient sample is gone, so it cannot prop the rate up. + assert_eq!(row.recent.len(), 1); + assert_eq!(row.down, 6000); + } + + #[test] + fn units_are_decimal_as_isps_quote_them() { + assert_eq!(units(1000.0), "1.0 KB"); + assert_eq!(units(2_500_000.0), "2.5 MB"); + assert_eq!(units(3_000_000_000.0), "3.0 GB"); + assert_eq!(units(512.0), "512 B"); + } + + #[test] + fn traffic_that_never_leaves_is_recognised() { + let own = vec!["10.0.0.46".to_string(), "100.64.0.102".to_string()]; + assert!(local_peer("127.0.0.1", &own)); + assert!(local_peer("::1", &own)); + // The half that is easy to miss: our own non-loopback address. + assert!(local_peer("10.0.0.46", &own)); + assert!(local_peer("::ffff:10.0.0.46", &own)); + assert!(!local_peer("10.0.0.99", &own)); + } + + #[test] + fn only_globally_routable_peers_are_off_box() { + let own = vec!["10.0.0.46".to_string()]; + assert!(off_box("203.0.113.10", &own)); + assert!(!off_box("10.0.0.5", &own)); + assert!(!off_box("172.16.0.1", &own)); + assert!(!off_box("192.168.1.1", &own)); + assert!(!off_box("100.64.0.102", &own)); + assert!(!off_box("127.0.0.1", &own)); + // 172.32 is outside the private range and really is out there. + assert!(off_box("172.32.0.1", &own)); + } + + #[test] + fn a_cgroup_names_the_daemon() { + assert_eq!(unit_name("/system.slice/tailscaled.service"), "tailscaled"); + assert_eq!( + unit_name("/system.slice/google-guest-agent.service"), + "google-guest-agent" + ); + // A login session says who is logged in, not what opened the socket. + assert_eq!(unit_name("/user.slice/user-1002.slice/session-1.scope"), ""); + assert_eq!(unit_name("/"), ""); + } + + #[test] + fn ss_fields_are_read_off_the_line() { + let line = "\t ts sack cubic bytes_sent:1669 bytes_acked:1670 bytes_received:11469 segs_out:272"; + assert_eq!(field(line, "bytes_sent:"), Some("1669".into())); + assert_eq!(field(line, "bytes_received:"), Some("11469".into())); + assert_eq!(field(line, "nothing:"), None); + } + + #[test] + fn virtual_interfaces_are_not_the_wire() { + // Counting a tunnel as well as the card counts a forwarded packet + // twice, which is the whole reason for the exclusion list. + for name in ["lo", "tailscale0", "docker0", "veth1234"] { + assert!(VIRTUAL.iter().any(|v| name.starts_with(v)), "{}", name); + } + for name in ["ens4", "eth0", "wlan0"] { + assert!(!VIRTUAL.iter().any(|v| name.starts_with(v)), "{}", name); + } + } + + #[test] + fn an_idle_series_draws_nothing() { + let grid = braille_canvas(&[0.0, 0.0, 0.0, 0.0], 1.0, 10, 2, false); + assert!(grid.iter().all(|row| row.iter().all(|c| *c == 0))); + } + + #[test] + fn a_spike_puts_dots_on_the_canvas() { + let grid = braille_canvas(&[0.0, 5.0, 0.0], 5.0, 10, 2, false); + assert!(grid.iter().any(|row| row.iter().any(|c| *c != 0))); + } + + #[test] + fn the_interface_label_names_few_and_counts_many() { + assert_eq!(wire_label(&["ens4".into()]), "ens4"); + assert_eq!(wire_label(&["ens4".into(), "eth1".into()]), "ens4, eth1"); + let many: Vec<String> = (0..5).map(|i| format!("eth{}", i)).collect(); + assert_eq!(wire_label(&many), "5 of them"); + } +} diff --git a/widgets/src/bin/netwatch_help.txt b/widgets/src/bin/netwatch_help.txt new file mode 100644 index 0000000..8d74310 --- /dev/null +++ b/widgets/src/bin/netwatch_help.txt @@ -0,0 +1,33 @@ +Which processes are using the network, how much, and how fast. + +`nettop` answers this on macOS and has no equivalent here. What Linux does +have is the kernel's own per-socket accounting: `ss -tine` reports bytes_sent +and bytes_received for every TCP socket along with its inode, and the inode +appears in /proc/<pid>/fd, which is what ties bytes to a process. No packet +capture, no kernel module, no root. + + netwatch [-i SECONDS] [-n COUNT] [--sort total|live] + [--external] [--plain] + +Only traffic that leaves the machine is counted. Loopback is excluded, and so +is any connection to one of this machine's own addresses - talking to your own +10.x or tailnet address never reaches a wire, however external it looks in the +socket table. --external is the narrower question of internet-only, and drops +the local network and the tailnet too. + +Totals start at zero: the first sample is a baseline and only what happens +after it is counted. A process that exits keeps what it used, marked so, since +"what has been eating the connection" is usually asked after the thing has +stopped. + +TCP only, which is the honest limit of this method - see docs/netwatch.md. + +Enter opens one process: its command, every connection it holds separately, +and the files it currently has open with how fast each is growing - which is +the closest thing to "which file is it downloading" that exists outside the +encrypted stream. The URL and the remote filename are inside TLS and are not +readable from here by any means. + +Keys: up/down select, enter opens one, esc goes back, 1 sorts by total, +2 by current rate, o shows the daemons you do not own, r rezeroes, +q quits. diff --git a/widgets/src/bin/ports.rs b/widgets/src/bin/ports.rs new file mode 100644 index 0000000..1afd2cb --- /dev/null +++ b/widgets/src/bin/ports.rs @@ -0,0 +1,3341 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! What is listening on this machine, what started it, and who can reach it. +//! +//! A port of ports.py. Same sources - the kernel's socket table for the +//! ports, each process's own cmdline and cwd for the rest - and deliberately +//! the same behaviour on screen, so the two can be compared side by side +//! while the rest of the collection is translated. +//! +//! ports [-n SECONDS] +//! +//! Keys: up/down select, o hides the machine's own ports, r refreshes, +//! q quits. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use toys_core as tc; + +/// The machine's own ports, hidden behind `o` by default: they are never +/// the answer to "which port is my dev server on". +const SYSTEM_PORTS: &[u16] = &[22, 53, 123, 323, 631, 5353]; + +/// What config said, if it said anything. Set once in main. +/// +/// A static rather than a threaded parameter because two predicates deep +/// in the sort and filter want it and neither is worth rewriting to carry +/// a list that changes only at startup. +static CONFIGURED_PORTS: std::sync::OnceLock<Vec<u16>> = std::sync::OnceLock::new(); + +/// Whether a port belongs to the machine rather than to something started. +/// The configured system-port list, or None when the key is absent. +/// +/// Present-and-empty is an answer: hide nothing. Absent means use the +/// built-in defaults. A number that is not a port is dropped rather than +/// wrapped - `65558 as u16` is 22, and hiding SSH because of a typo is +/// worse than ignoring the typo. +fn configured_system_ports(cfg: &serde_json::Value) -> Option<Vec<u16>> { + // Asking whether the key is there has no value to default: empty + // means hide nothing, absent means the built-in list. + if cfg.get("system_ports").is_none() { + return None; + } + Some( + cfg["system_ports"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_u64()) + .filter_map(|n| u16::try_from(n).ok()) + .collect() + }) + .unwrap_or_default(), + ) +} + +fn is_system_port(port: u16) -> bool { + match CONFIGURED_PORTS.get() { + Some(list) => list.contains(&port), + None => SYSTEM_PORTS.contains(&port), + } +} + +/// Per-port traffic, sample by sample. +/// +/// Each entry is one poll's worth: bytes out, bytes in, and how long the +/// gap to the previous sample actually was. The elapsed time is recorded +/// rather than assumed, because `[r]` polls early and a rate computed +/// against the nominal interval would read high every time someone pressed +/// it. +#[derive(Default)] +struct Traffic { + last: HashMap<String, Counters>, + at: f64, + seen: HashMap<u16, VecDeque<(u64, u64, f64)>>, + /// The same samples summed over every listening port, kept separately + /// rather than added up at render time. Ports appear and disappear + /// mid-history, so their rings are different lengths and summing them + /// by index would quietly attribute one port's sample to another's + /// moment. + total: VecDeque<(u64, u64, f64)>, +} + +impl Traffic { + /// Fold one sample in, and forget ports nothing is listening on. + fn sample(&mut self, text: &str, listening: &[u16], at: f64) { + let now = socket_counters(text); + let gap = at - self.at; + // The first sample has nothing to subtract from, and a gap of zero + // would divide a rate by nothing. + if self.at > 0.0 && gap > 0.0 { + let moved_now = moved(&self.last, &now, listening); + for &port in listening { + let (up, down) = moved_now.get(&port).copied().unwrap_or((0, 0)); + let ring = self.seen.entry(port).or_default(); + ring.push_back((up, down, gap)); + while ring.len() > TRAFFIC_KEPT { + ring.pop_front(); + } + } + // One entry per sample whether or not anything moved, so the + // chart's columns are moments rather than events - a gap in it + // would compress quiet minutes into no width at all. + let (up, down) = moved_now + .values() + .fold((0u64, 0u64), |(a, b), (u, d)| (a + u, b + d)); + self.total.push_back((up, down, gap)); + while self.total.len() > TRAFFIC_KEPT { + self.total.pop_front(); + } + } + self.seen.retain(|port, _| listening.contains(port)); + self.last = now; + self.at = at; + } + + /// The most recent sample as a rate, in bytes per second. + fn rate(&self, port: u16) -> Option<(f64, f64)> { + let (up, down, gap) = *self.seen.get(&port)?.back()?; + (gap > 0.0).then(|| (up as f64 / gap, down as f64 / gap)) + } + + /// Every kept sample as a rate, oldest first, each with the gap it was + /// measured over - so a chart showing only the last so many of them can + /// say how much history *that* is, rather than how much is kept. + fn series(&self, port: u16) -> Vec<(f64, f64, f64)> { + self.seen.get(&port).map(|r| rates(r)).unwrap_or_default() + } + + /// Every sample summed over all the listening ports. + fn totals(&self) -> Vec<(f64, f64, f64)> { + rates(&self.total) + } +} + +/// A ring of byte counts as rates, oldest first, each keeping the gap it +/// was measured over so a chart can say how much history it is showing. +fn rates(ring: &VecDeque<(u64, u64, f64)>) -> Vec<(f64, f64, f64)> { + ring.iter() + .filter(|(_, _, gap)| *gap > 0.0) + .map(|(up, down, gap)| (*up as f64 / gap, *down as f64 / gap, *gap)) + .collect() +} + +/// How many samples of per-port traffic to keep, which is what the chart on +/// a port's own screen is drawn from. +/// +/// The chart draws one column per sample, so this has to outlast the widest +/// pane anyone opens it in. At the default four-second poll it is a little +/// over half an hour. +const TRAFFIC_KEPT: usize = 512; + +/// One TCP socket's byte counters, as the kernel has them. +/// +/// Keyed by inode because that is what identifies a socket across samples. +/// The port is the *local* one: traffic to a listening port arrives on the +/// connections accepted from it, and the listening socket itself carries +/// none. +#[derive(Clone, Copy, Default, PartialEq, Debug)] +struct Counters { + port: u16, + sent: u64, + recv: u64, +} + +/// Every TCP socket's byte counters, keyed by inode. +/// +/// `-i` for the counters, `-e` for the inode - the same two flags netwatch +/// asks for, and the same two-line shape: addresses and inode, then the +/// counters on an indented continuation. +/// +/// Unlike netwatch this filters nothing. netwatch drops loopback peers +/// because it is about what leaves the machine; here the loopback peers are +/// the whole point, since a browser hitting a dev server on 127.0.0.1 is +/// the traffic being asked about. +fn socket_counters(text: &str) -> HashMap<String, Counters> { + let mut found = HashMap::new(); + let (mut inode, mut port) = (None, 0u16); + for (i, line) in text.lines().enumerate() { + if i == 0 && line.starts_with("State") { + continue; + } + if !line.starts_with(' ') && !line.starts_with('\t') { + let cols: Vec<&str> = line.split_whitespace().collect(); + // Column 3 is our address, and its port is the one a listener + // would be on. + port = cols + .get(3) + .and_then(|a| a.rsplit_once(':')) + .and_then(|(_, p)| p.parse().ok()) + .unwrap_or(0); + inode = counter_field(line, "ino:").filter(|v| v != "0"); + continue; + } + let Some(id) = inode.take() else { continue }; + found.insert( + id, + Counters { + port, + sent: counter_field(line, "bytes_sent:") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + recv: counter_field(line, "bytes_received:") + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + }, + ); + } + found +} + +/// The value after `key:` on a line, up to the next space. +fn counter_field(line: &str, key: &str) -> Option<String> { + let at = line.find(key)? + key.len(); + let rest = &line[at..]; + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + Some(rest[..end].to_string()).filter(|v| !v.is_empty()) +} + +/// What moved on each listening port between two samples. +/// +/// Only sockets present in *both* count. A socket that appeared since the +/// last sample has no previous total to subtract, and one that closed took +/// its last few bytes with it - said out loud in the doc rather than +/// guessed at here. +/// +/// Inodes are reused after a socket closes, so a raw subtraction can go +/// negative; it is clamped rather than wrapped. And only ports something is +/// actually listening on are tallied, because most established sockets are +/// outbound and their local port is an ephemeral one that means nothing. +fn moved( + before: &HashMap<String, Counters>, + now: &HashMap<String, Counters>, + listening: &[u16], +) -> HashMap<u16, (u64, u64)> { + let mut out: HashMap<u16, (u64, u64)> = HashMap::new(); + for (id, c) in now { + if !listening.contains(&c.port) { + continue; + } + let Some(was) = before.get(id) else { continue }; + // A reused inode is a different socket wearing the same name; its + // counters start again and the subtraction is meaningless. + if was.port != c.port { + continue; + } + let slot = out.entry(c.port).or_default(); + slot.0 += c.sent.saturating_sub(was.sent); + slot.1 += c.recv.saturating_sub(was.recv); + } + out +} + +/// Whether the two traffic columns fit, given what the row must show first. +/// +/// Both are measured against a row that already has UP and EXPOSED on it, +/// whether or not this pane is wide enough to be showing them yet. Without +/// that, crossing the width where those two arrive pushed TRAFFIC back off +/// again - a pane that got wider and said less, which is the same fault the +/// linear board had and the reason this is a function with a test rather +/// than arithmetic inline. +/// +/// Returns whether the rates fit, and whether the shapes fit after them. +fn extra_columns( + w: usize, + kind_w: usize, + project_w: usize, + traffic_w: usize, + spark_w: usize, +) -> (bool, bool) { + // Marker and port, bind, the gap after the name, and the two columns at + // the end - counted always, so nothing here trades places with them. + let fixed = 1 + 6 + 8 + 2 + 6 + 8; + let room = |extra: usize| w.saturating_sub(1) >= fixed + project_w + kind_w + extra; + let busy = room(traffic_w); + (busy, busy && room(traffic_w + spark_w)) +} + +/// The widest name in the table, which is what the WHAT column has to be. +fn longest_kind(rows: &[&Row]) -> usize { + rows.iter().map(|r| r.kind.chars().count()).max().unwrap_or(0).max(4) +} + +/// A rate in as few cells as it can be read in, for a table column. +/// +/// One significant figure and a single-letter unit: five cells at most, so +/// two of them and their arrows fit in thirteen. +fn brief(n: f64) -> String { + for (suffix, scale) in [("G", 1e9), ("M", 1e6), ("K", 1e3)] { + if n >= scale { + let v = n / scale; + return if v < 10.0 { + format!("{:.1}{}", v, suffix) + } else { + format!("{:.0}{}", v, suffix) + }; + } + } + format!("{:.0}B", n) +} + +/// A byte count at whatever unit keeps it readable. +fn units(n: f64) -> String { + for (suffix, scale) in [("GB", 1e9), ("MB", 1e6), ("KB", 1e3)] { + if n >= scale { + return format!("{:.1} {}", n / scale, suffix); + } + } + format!("{} B", n as i64) +} + +fn rate_of(n: f64) -> String { + if n > 0.0 { + format!("{}/s", units(n)) + } else { + "-".into() + } +} + +/// A span at whatever unit keeps it readable. +fn over(seconds: f64) -> String { + let s = seconds as i64; + if s < 60 { + format!("{}s", s) + } else if s < 3600 { + format!("{}m", s / 60) + } else { + format!("{}h{}m", s / 3600, (s % 3600) / 60) + } +} + +/// One row's worth of shape: the last `cells` samples of a port's traffic, +/// both directions together, in a single line of bars. +/// +/// Scaled to its *own* peak, not to the busiest port on screen. A shared +/// scale would flatten every quiet port to nothing, and nothing is what a +/// port with no traffic looks like. So this column says shape and the +/// TRAFFIC column beside it says size - and the two are read together. +/// +/// Cells with no sample behind them are dotted, the same as the chart on the +/// port's own screen, because a port nobody has measured yet and a port +/// nobody is calling are not the same thing. +fn spark(series: &[(f64, f64, f64)], cells: usize, p: &Palette) -> Vec<(String, String)> { + if cells == 0 { + return Vec::new(); + } + let window: Vec<f64> = series.iter().rev().take(cells).rev().map(|s| s.0 + s.1).collect(); + let peak = window.iter().copied().fold(0.0f64, f64::max); + let mut out = Vec::new(); + if window.len() < cells { + out.push((p.grid.clone(), "·".repeat(cells - window.len()))); + } + if peak <= 0.0 { + // Measured, and nothing moved: a flat baseline. Blank would put it + // in the same shape as a port nothing has sampled yet, and the + // dotted cells beside it exist precisely to keep those apart. + out.push((p.grid.clone(), "─".repeat(window.len()))); + return out; + } + let cols: Vec<(f64, String)> = window.iter().map(|v| (*v, p.open.clone())).collect(); + for row in tc::vbars(&cols, 1, peak) { + for (colour, text) in row { + out.push((colour, text)); + } + } + out +} + +/// Traffic on one port over time: out above the line, in below it. +/// +/// The chart is as wide as the pane. One column is one sample, newest at the +/// right, and until there is enough history to fill it the left of the plot +/// carries a dotted baseline rather than bars of no height: a flat line +/// there would say the port was quiet then, and it says nothing of the sort. +/// +/// Each direction is scaled to its own peak and each says what that peak +/// was, in a gutter down the left so the plot keeps the rest of the width. A +/// shared scale would flatten the smaller of the two into nothing, and +/// nothing is what a source with no traffic looks like. +fn traffic_chart( + series: &[(f64, f64, f64)], + heading: &str, + rows: usize, + gap: f64, + w: usize, + p: &Palette, +) -> Vec<String> { + let up_peak = series.iter().map(|s| s.0).fold(0.0f64, f64::max); + let down_peak = series.iter().map(|s| s.1).fold(0.0f64, f64::max); + // The gutter is as wide as the wider of the two labels, so the plots + // line up under each other and the divider spans exactly the plot. + let label = |arrow: &str, peak: f64| { + if peak > 0.0 { + format!("{} {}", arrow, rate_of(peak)) + } else { + String::new() + } + }; + let (up_label, down_label) = (label("↑", up_peak), label("↓", down_peak)); + let lab = up_label + .chars() + .count() + .max(down_label.chars().count()) + .clamp(4, 16); + let plot = (w - 1).saturating_sub(lab + 2).max(12); + // The most recent `plot` samples, and how far back that reaches - which + // is not how far back the kept history reaches once it has overflowed. + let window: Vec<(f64, f64, f64)> = series.iter().rev().take(plot).rev().copied().collect(); + let reach: f64 = window.iter().map(|s| s.2).sum(); + let blank = plot - window.len(); + + let mut out = vec![tc::seg( + &[ + (p.lbl.as_str(), format!(" ── {} ── ", heading)), + (p.open.as_str(), "↑ out above".into()), + (p.dim.as_str(), " · ".into()), + (p.local.as_str(), "↓ in below".into()), + // The window is named, because a chart of an unnamed window is a + // shape rather than a measurement. + ( + p.dim.as_str(), + if window.is_empty() { + String::new() + } else { + format!(" · {} of history, sampled every {}", over(reach), over(gap)) + }, + ), + ], + w - 1, + )]; + if window.iter().all(|(a, b, _)| *a == 0.0 && *b == 0.0) { + out.push(tc::seg( + &[( + p.dim.as_str(), + if series.is_empty() { + " waiting for a second sample".into() + } else { + " nothing has moved on it".to_string() + }, + )], + w - 1, + )); + return out; + } + + let half = |pick: fn(&(f64, f64, f64)) -> f64, + colour: &str, + peak: f64, + text: &str, + down: bool| + -> Vec<String> { + let cols: Vec<(f64, String)> = window.iter().map(|s| (pick(s), colour.to_string())).collect(); + let bars = if down { + tc::vbars_down(&cols, rows, peak) + } else { + tc::vbars(&cols, rows, peak) + }; + // The label sits on the row nearest the divider, which is the row a + // full-height bar reaches: the top for the upward half, the first + // drawn row for the downward one. + let on = if down { 0 } else { bars.len().saturating_sub(1) }; + bars.into_iter() + .enumerate() + .map(|(i, row)| { + let mut line: Vec<(&str, String)> = vec![( + colour, + format!("{:>1$} ", if i == on { text } else { "" }, lab), + )]; + // Where there is no history yet, a dotted baseline on the + // row against the divider. Left blank it would be + // indistinguishable from a stretch of real zeroes, and a + // quiet port and an unmeasured one are not the same thing - + // which is the one confusion this widget must never cause. + if blank > 0 { + if i == on { + line.push((p.grid.as_str(), "·".repeat(blank))); + } else { + line.push((tc::RST, " ".repeat(blank))); + } + } + for (c, t) in &row { + line.push((c.as_str(), t.clone())); + } + tc::seg(&line, w - 1) + }) + .collect() + }; + out.extend(half(|s| s.0, p.open.as_str(), up_peak, &up_label, false)); + out.push(tc::seg( + &[ + (p.dim.as_str(), " ".repeat(lab + 1)), + (p.grid.as_str(), "─".repeat(plot)), + ], + w - 1, + )); + out.extend(half(|s| s.1, p.local.as_str(), down_peak, &down_label, true)); + out +} + +/// Process titles worth recognising, first match winning, so the specific +/// ones come before `node` and `python`. +const KINDS: &[(&str, &str)] = &[ + ("next-server", "Next.js"), + ("node_modules/next/dist", "Next.js"), + ("node_modules/.bin/vite", "Vite"), + ("react-scripts", "React"), + ("webpack", "webpack"), + ("nuxt", "Nuxt"), + ("astro", "Astro"), + ("remix", "Remix"), + ("uvicorn", "uvicorn"), + ("gunicorn", "gunicorn"), + ("manage.py", "Django"), + ("rails", "Rails"), + ("postgres", "Postgres"), + ("redis-server", "Redis"), + ("mysqld", "MySQL"), + ("mongod", "MongoDB"), + ("docker-proxy", "Docker"), + ("ollama", "Ollama"), + ("code-server", "VS Code"), + ("herdr", "Herdr"), + ("tailscaled", "Tailscale"), + ("sshd", "SSH"), + ("systemd-resolve", "DNS"), +]; + +/// The two that are runtimes rather than programs, and so have to be the +/// command being run rather than a word anywhere in its path. +/// +/// The Python anchors these - `[/ ]node(\s|$)` and `[/ ]python[0-9.]*(\s|$)` +/// - and the port flattened both to a plain substring. Every binary that +/// lives under a directory with `node` or `python` in it then answered to +/// the wrong name: a standalone tool installed into `node_modules` was +/// listed as "Node", which is the runtime it is not written in. +const RUNTIMES: &[(&str, &str)] = &[("python", "Python"), ("node", "Node")]; + +/// Whether a command line actually runs `name`, rather than merely passing +/// through a directory called that. +/// +/// A trailing version is part of the name - `python3`, `python3.11` - which +/// is what the `[0-9.]*` in the Python's pattern is for. +fn runs_command(cmdline: &str, name: &str) -> bool { + cmdline.split_whitespace().any(|word| { + let base = word.rsplit('/').next().unwrap_or(word); + match base.strip_prefix(name) { + Some("") => true, + Some(tail) => tail.chars().all(|c| c.is_ascii_digit() || c == '.'), + None => false, + } + }) +} + +/// Ports whose owner is usually root, so /proc will not say what it is. +/// Naming them by convention is a guess, and is marked as one. +const BY_PORT: &[(u16, &str)] = &[ + (22, "SSH"), + (53, "DNS"), + (80, "HTTP"), + (123, "NTP"), + (443, "HTTPS"), + (631, "printing"), + (3306, "MySQL"), + (5432, "Postgres"), + (6379, "Redis"), + (5353, "mDNS"), + (27017, "MongoDB"), +]; + +// cmdline, cwd and families are carried for the detail screen; the table +// itself has no room for them. +#[derive(Clone, Default)] +struct Row { + port: u16, + bind: String, + families: u8, + pid: Option<i32>, + cmdline: String, + cwd: String, + kind: String, + guessed: bool, + user: String, + project: String, + gone: bool, + up: Option<f64>, + exposed: String, + orphan: bool, +} + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// Who can reach a socket bound to this address. +fn bind_class(bind: &str) -> String { + if bind == "0.0.0.0" || bind == "::" { + return "all".into(); + } + if bind.starts_with("127.") || bind == "::1" { + return "local".into(); + } + if is_tailnet_v4(bind) || bind.starts_with("fd7a:115c:a1e0") { + return "tailnet".into(); + } + bind.to_string() +} + +/// Tailscale hands out 100.64.0.0/10, which is a different answer from +/// either "all" or "local". +fn is_tailnet_v4(bind: &str) -> bool { + let mut parts = bind.split('.'); + match (parts.next(), parts.next()) { + (Some("100"), Some(second)) => second + .parse::<u16>() + .map(|n| (64..=127).contains(&n)) + .unwrap_or(false), + _ => false, + } +} + +/// The bind address out of /proc's little-endian hex. +fn hex_addr(text: &str) -> String { + if text.len() == 8 { + let n = u32::from_str_radix(text, 16).unwrap_or(0); + return format!( + "{}.{}.{}.{}", + n & 0xff, + (n >> 8) & 0xff, + (n >> 16) & 0xff, + (n >> 24) & 0xff + ); + } + if text.len() == 32 { + // Written as four 32-bit words, each little-endian: the four bytes + // of a word appear in reverse of the order they take in the + // address. Reverse each word and the sixteen bytes are in order. + let mut groups = Vec::new(); + for word in 0..4 { + let raw = &text[word * 8..word * 8 + 8]; + let mut bytes = [0u8; 4]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&raw[i * 2..i * 2 + 2], 16).unwrap_or(0); + } + bytes.reverse(); + groups.push(format!("{:02x}{:02x}", bytes[0], bytes[1])); + groups.push(format!("{:02x}{:02x}", bytes[2], bytes[3])); + } + return compress_v6(&groups); + } + "?".into() +} + +/// The conventional shortest form of an IPv6 address. +fn compress_v6(groups: &[String]) -> String { + let trimmed: Vec<String> = groups + .iter() + .map(|g| g.trim_start_matches('0').to_string()) + .map(|g| if g.is_empty() { "0".into() } else { g }) + .collect(); + let (mut best_at, mut best_len, mut at, mut len) = (usize::MAX, 0usize, usize::MAX, 0usize); + for (i, g) in trimmed.iter().enumerate() { + if g == "0" { + if len == 0 { + at = i; + } + len += 1; + if len > best_len { + best_len = len; + best_at = at; + } + } else { + len = 0; + } + } + if best_len < 2 { + return trimmed.join(":"); + } + let head = trimmed[..best_at].join(":"); + let tail = trimmed[best_at + best_len..].join(":"); + format!("{}::{}", head, tail) +} + +struct Socket { + port: u16, + bind: String, + inode: String, + uid: u32, +} + +/// Every listening TCP socket, from the kernel's own table. +fn listening() -> Vec<Socket> { + let mut out = Vec::new(); + for path in ["/proc/net/tcp", "/proc/net/tcp6"] { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(_) => continue, + }; + for line in text.lines().skip(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 10 || cols[3] != "0A" { + continue; + } + let (addr, port) = match cols[1].rsplit_once(':') { + Some(pair) => pair, + None => continue, + }; + let port = match u16::from_str_radix(port, 16) { + Ok(p) => p, + Err(_) => continue, + }; + out.push(Socket { + port, + bind: hex_addr(addr), + inode: cols[9].to_string(), + // The uid is in the table even where the process behind it + // is not reachable, which is the difference between + // "somebody else's" and "a mystery". + uid: cols[7].parse().unwrap_or(0), + }); + } + } + out +} + +/// inode -> pid, for every process this user can read. +/// +/// Root's sockets are not readable, so sshd and the like arrive unowned. +/// That is stated on screen rather than papered over. +fn socket_owners() -> HashMap<String, i32> { + let mut owners = HashMap::new(); + let entries = match std::fs::read_dir("/proc") { + Ok(e) => e, + Err(_) => return owners, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + let pid: i32 = match name.parse() { + Ok(p) => p, + Err(_) => continue, + }; + let fds = match std::fs::read_dir(format!("/proc/{}/fd", pid)) { + Ok(f) => f, + Err(_) => continue, + }; + for fd in fds.flatten() { + if let Ok(target) = std::fs::read_link(fd.path()) { + let target = target.to_string_lossy(); + if let Some(rest) = target.strip_prefix("socket:[") { + owners.insert(rest.trim_end_matches(']').to_string(), pid); + } + } + } + } + owners +} + +/// Whose socket it is, by name where the machine has one. +fn owner_name(uid: u32) -> String { + if uid == unsafe { libc::getuid() } { + return String::new(); + } + let entry = unsafe { libc::getpwuid(uid) }; + if entry.is_null() { + return format!("uid {}", uid); + } + let name = unsafe { std::ffi::CStr::from_ptr((*entry).pw_name) }; + name.to_string_lossy().to_string() +} + +fn process_info(pid: i32) -> (String, String, Option<f64>) { + let cmdline = std::fs::read(format!("/proc/{}/cmdline", pid)) + .map(|raw| { + String::from_utf8_lossy(&raw) + .replace('\0', " ") + .trim() + .to_string() + }) + .unwrap_or_default(); + let cwd = std::fs::read_link(format!("/proc/{}/cwd", pid)) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + // The link resolves even when the directory is gone; the kernel just + // marks it, and that marker is worth keeping. + let deleted = std::fs::metadata(format!("/proc/{}/cwd", pid)).is_err() && !cwd.is_empty(); + let cwd = if deleted && !cwd.ends_with("(deleted)") { + format!("{} (deleted)", cwd) + } else { + cwd + }; + let started = std::fs::metadata(format!("/proc/{}", pid)) + .ok() + .and_then(|m| m.created().or_else(|_| m.modified()).ok()) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs_f64()); + (cmdline, cwd, started) +} + +/// What a directory calls itself, for the label a person would use. +fn project_name(cwd: &str) -> String { + if cwd.is_empty() { + return String::new(); + } + let real = cwd.trim_end_matches(" (deleted)"); + let named = std::fs::read_to_string(format!("{}/package.json", real)) + .ok() + .and_then(|text| json_string(&text, "name")); + let base = std::path::Path::new(real) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let name = named.unwrap_or(base); + if cwd.ends_with("(deleted)") { + format!("{} ✗", name) + } else { + name + } +} + +/// The first string value for a top-level key, without a JSON parser. +/// +/// Enough for `package.json`'s name and for tailscale's serve status: both +/// are shapes this only needs one field out of, and a parser for them would +/// be a dependency bought for two lookups. +fn json_string(text: &str, key: &str) -> Option<String> { + let needle = format!("\"{}\"", key); + let at = text.find(&needle)? + needle.len(); + let rest = &text[at..]; + let colon = rest.find(':')? + 1; + let rest = &rest[colon..]; + let open = rest.find('"')? + 1; + let rest = &rest[open..]; + let close = rest.find('"')?; + Some(rest[..close].to_string()) +} + +/// What sort of server this is, from the process itself. +fn kind_of(cmdline: &str, port: u16) -> (String, bool) { + if !cmdline.is_empty() { + for (needle, name) in KINDS.iter().chain(RUNTIMES) { + if if RUNTIMES.iter().any(|(n, _)| n == needle) { + runs_command(cmdline, needle) + } else { + cmdline.contains(needle) + } { + // Next.js rewrites its own title to next-server (v16.3.0), + // which hands over the framework and the version at once. + if let Some(version) = version_in(cmdline) { + return (format!("{} {}", name, version), false); + } + return (name.to_string(), false); + } + } + let first = cmdline.split_whitespace().next().unwrap_or(""); + let base = first.rsplit('/').next().unwrap_or(first); + if !base.is_empty() { + return (base.to_string(), false); + } + } + for (known, name) in BY_PORT { + if *known == port { + return (format!("{}?", name), true); + } + } + (String::new(), false) +} + +fn version_in(cmdline: &str) -> Option<String> { + let at = cmdline.find("(v")?; + let rest = &cmdline[at + 2..]; + let close = rest.find(')')?; + let version = &rest[..close]; + if version.chars().next()?.is_ascii_digit() { + Some(version.to_string()) + } else { + None + } +} + +/// Seconds before an external command is given up on, from ports.py's longest, on the serve/funnel commands. +const RUN_TIMEOUT: u64 = 30; + +fn run(args: &[&str]) -> String { + // Bounded: .output() waits forever, and a wedged child used to freeze + // the poll thread with the pane still showing its last frame. + tc::run(args, RUN_TIMEOUT).unwrap_or_default() +} + +/// Ports Tailscale is serving, and whether the world can see them. +fn exposure() -> HashMap<u16, String> { + let mut served = HashMap::new(); + let text = run(&["tailscale", "serve", "status", "--json"]); + for port in proxied_ports(&text) { + served.insert(port, "tailnet".to_string()); + } + let funnel = run(&["tailscale", "funnel", "status"]); + if !funnel.contains("tailnet only") { + for port in proxied_ports(&funnel) { + served.insert(port, "public".to_string()); + } + } + served +} + +/// Every local port a proxy line points at, in either output shape. +fn proxied_ports(text: &str) -> Vec<u16> { + let mut found = Vec::new(); + for marker in ["127.0.0.1:", "localhost:", "[::1]:"] { + let mut rest = text; + while let Some(at) = rest.find(marker) { + rest = &rest[at + marker.len()..]; + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(port) = digits.parse::<u16>() { + found.push(port); + } + } + } + found +} + +/// One entry per listening service, plus anything served but not bound. +fn scan() -> Vec<Row> { + let owners = socket_owners(); + let served = exposure(); + let mut rows: Vec<Row> = Vec::new(); + let mut services: HashMap<(u16, Option<i32>, String), usize> = HashMap::new(); + let mut seen: Vec<u16> = Vec::new(); + let stamp = now(); + + for sock in listening() { + let pid = owners.get(&sock.inode).copied(); + // A server on both address families is two sockets in the kernel + // table but one thing to know about. Any of port, owner or + // reachability differing is a real second row. + let key = (sock.port, pid, bind_class(&sock.bind)); + if let Some(&at) = services.get(&key) { + rows[at].families += 1; + continue; + } + let (cmdline, cwd, started) = match pid { + Some(pid) => process_info(pid), + None => (String::new(), String::new(), None), + }; + let (kind, guessed) = kind_of(&cmdline, sock.port); + let row = Row { + port: sock.port, + bind: sock.bind.clone(), + families: 1, + pid, + cmdline, + cwd: cwd.clone(), + kind, + guessed, + user: owner_name(sock.uid), + project: project_name(&cwd), + gone: cwd.ends_with("(deleted)"), + up: started.map(|s| stamp - s), + exposed: served.get(&sock.port).cloned().unwrap_or_default(), + orphan: false, + }; + services.insert(key, rows.len()); + seen.push(sock.port); + rows.push(row); + } + + // A port Tailscale forwards to with nothing behind it is worth its own + // row: the URL exists, answers 502, and nothing in lsof explains why. + for (port, how) in &served { + if !seen.contains(port) { + rows.push(Row { + port: *port, + kind: "nothing listening".into(), + exposed: how.clone(), + orphan: true, + ..Default::default() + }); + } + } + rows.sort_by_key(|r| (is_system_port(r.port), r.port)); + rows +} + +fn span(seconds: Option<f64>) -> String { + let s = match seconds { + Some(s) if s >= 0.0 => s, + _ => return "--".into(), + }; + if s < 60.0 { + format!("{}s", s as i64) + } else if s < 3600.0 { + format!("{}m", (s / 60.0) as i64) + } else if s < 86400.0 { + format!("{}h", (s / 3600.0) as i64) + } else { + format!("{}d", (s / 86400.0) as i64) + } +} + +/// Whether a row is part of the machine rather than something you started. +fn theirs(row: &Row) -> bool { + if row.orphan { + return false; + } + is_system_port(row.port) || !row.user.is_empty() +} + +/// Every address this machine holds, by interface. +/// +/// Link-local is dropped: an fe80:: address needs a zone index to be usable +/// and is never what somebody wants pasted into a browser. +fn interfaces() -> Vec<(String, String, bool)> { + let mut found = Vec::new(); + let data: serde_json::Value = + serde_json::from_str(&run(&["ip", "-j", "addr"])).unwrap_or(serde_json::Value::Null); + for link in data.as_array().unwrap_or(&Vec::new()) { + let name = link["ifname"].as_str().unwrap_or("?").to_string(); + for addr in link["addr_info"].as_array().unwrap_or(&Vec::new()) { + let ip = addr["local"].as_str().unwrap_or(""); + if ip.is_empty() || ip.starts_with("fe80:") { + continue; + } + found.push(( + name.clone(), + ip.to_string(), + addr["family"].as_str() == Some("inet6"), + )); + } + } + found +} + +#[derive(Clone, Default)] +struct Net { + name: String, + ips: Vec<String>, + funnel: bool, + operator: bool, +} + +/// This node's tailnet name and addresses, and whether it may Funnel. +/// +/// Funnel is off unless the tailnet's policy grants the node the attribute, +/// and the node knows: the capability is in the map the coordination server +/// hands it. Asking here means the widget can say so instead of offering a +/// key that only ever returns an error. +fn tailnet_self() -> Net { + let mut out = Net::default(); + let data: serde_json::Value = serde_json::from_str(&run(&["tailscale", "status", "--json"])) + .unwrap_or(serde_json::Value::Null); + let node = &data["Self"]; + out.name = node["DNSName"] + .as_str() + .unwrap_or("") + .trim_end_matches('.') + .to_string(); + out.ips = node["TailscaleIPs"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + out.funnel = node["CapMap"] + .as_object() + .is_some_and(|m| m.keys().any(|k| k.contains("cap/funnel"))); + // Changing the serve config is a root operation unless this user has + // been named the operator. Worth knowing before the key is pressed, + // since the fix is a one-off command rather than anything this can do. + let prefs: serde_json::Value = serde_json::from_str(&run(&["tailscale", "debug", "prefs"])) + .unwrap_or(serde_json::Value::Null); + let who = prefs["OperatorUser"].as_str().unwrap_or(""); + out.operator = unsafe { libc::getuid() } == 0 || (!who.is_empty() && who == username()); + out +} + +fn username() -> String { + std::env::var("USER") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| owner_name(unsafe { libc::getuid() })) +} + +/// An address as it goes in a URL - IPv6 needs its brackets. +fn host_part(ip: &str, v6: bool) -> String { + if v6 { + format!("[{}]", ip) + } else { + ip.to_string() + } +} + +/// A URL for a host and port, with the scheme the port implies. +fn url_for(host: &str, v6: bool, port: u16) -> String { + let scheme = if port == 443 || port == 8443 { + "https" + } else { + "http" + }; + format!("{}://{}:{}", scheme, host_part(host, v6), port) +} + +/// Where this port can actually be reached, most local first. +/// +/// Bounded by what the socket is bound to, which is the part that gets got +/// wrong: a server on 127.0.0.1 is not reachable at this machine's LAN +/// address no matter how many addresses the machine has, and offering one +/// to copy would hand somebody a URL that cannot work. Only a socket bound +/// to every interface gets the full list. +/// +/// A served port is the exception worth keeping: Tailscale proxies to it +/// over loopback, so its https URL works even for a loopback-only server. +fn addresses(row: &Row, net: &Net, cfg: &serde_json::Value) -> Vec<(String, String)> { + let (port, reach) = (row.port, bind_class(&row.bind)); + let mut found = Vec::new(); + let served = served_url(cfg, port); + if !served.is_empty() { + found.push((served, "tailnet · via serve".to_string())); + } + // Nothing is listening, so every address below would refuse the + // connection. The serve URL above is the only one that exists, and it + // answers 502 - which is the whole reason this row is on screen. + if row.orphan { + return found; + } + if reach == "local" { + found.push(( + url_for("127.0.0.1", false, port), + "this machine only".to_string(), + )); + return found; + } + if reach == "tailnet" { + for ip in &net.ips { + found.push((url_for(ip, ip.contains(':'), port), "tailnet".to_string())); + } + if !net.name.is_empty() { + found.push(( + url_for(&net.name, false, port), + "tailnet · name".to_string(), + )); + } + return found; + } + if reach != "all" { + // Bound to one particular address, so that address is the answer. + found.push(( + url_for(&row.bind, row.bind.contains(':'), port), + "this interface".to_string(), + )); + return found; + } + found.push(( + url_for("127.0.0.1", false, port), + "this machine".to_string(), + )); + for (name, ip, v6) in interfaces() { + if ip.starts_with("127.") || ip == "::1" { + continue; + } + let note = if net.ips.iter().any(|t| *t == ip) { + "tailnet".to_string() + } else { + name + }; + found.push((url_for(&ip, v6, port), note)); + } + if !net.name.is_empty() { + found.push(( + url_for(&net.name, false, port), + "tailnet · name".to_string(), + )); + } + found +} + +/// Tailscale's own serve configuration, as it reports it. +/// +/// The JSON form rather than the text: the detail view needs the URL a +/// served port answers on, and putting a second port behind the same node +/// needs to know which mounts are already taken. Both are structure the +/// text output only implies. +fn serve_config() -> serde_json::Value { + serde_json::from_str(&run(&["tailscale", "serve", "status", "--json"])) + .unwrap_or(serde_json::Value::Null) +} + +/// Whether a proxy target names this port - `:3000` or `:3000/`, not +/// `:30001`, which a plain substring search would have accepted. +fn proxies_port(proxy: &str, port: u16) -> bool { + let want = format!(":{}", port); + let mut rest = proxy; + while let Some(at) = rest.find(&want) { + let after = &rest[at + want.len()..]; + if after.is_empty() || after.starts_with('/') { + return true; + } + rest = &rest[at + 1..]; + } + false +} + +/// The https URL a served port answers on, where one is configured. +fn served_url(cfg: &serde_json::Value, port: u16) -> String { + let web = match cfg["Web"].as_object() { + Some(w) => w, + None => return String::new(), + }; + for (mount, entry) in web { + for (path, handler) in entry["Handlers"].as_object().into_iter().flatten() { + if !proxies_port(handler["Proxy"].as_str().unwrap_or(""), port) { + continue; + } + let (host, listen) = mount.split_once(':').unwrap_or((mount.as_str(), "")); + let port_part = if listen.is_empty() || listen == "443" { + String::new() + } else { + format!(":{}", listen) + }; + return format!("https://{}{}{}", host, port_part, path); + } + } + String::new() +} + +#[derive(Clone)] +struct Tunnel { + pid: i32, + url: String, +} + +/// Where a launched quick tunnel's pid and URL are remembered. +/// +/// cloudflared holds no listening socket - it dials out - so nothing in +/// /proc ties it to the port it serves. Without a note on disk the widget +/// would lose a tunnel the moment it restarted, and leave it running with +/// no way to find or stop it. +fn tunnel_dir() -> String { + let base = std::env::var("XDG_STATE_HOME") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("{}/.local/state", std::env::var("HOME").unwrap_or_default())); + let path = format!("{}/terminal-toys/tunnels", base); + match std::fs::create_dir_all(&path) { + Ok(()) => path, + Err(_) => String::new(), + } +} + +/// The quick tunnel for a port: its pid, its URL, whether it still runs. +fn tunnel_state(port: u16) -> Option<Tunnel> { + let dir = tunnel_dir(); + if dir.is_empty() { + return None; + } + let text = std::fs::read_to_string(format!("{}/{}.json", dir, port)).ok()?; + let note: serde_json::Value = serde_json::from_str(&text).ok()?; + let pid = note["pid"].as_i64().unwrap_or(0) as i32; + if !alive(pid) { + forget_tunnel(port); + return None; + } + Some(Tunnel { + pid, + url: note["url"].as_str().unwrap_or("").to_string(), + }) +} + +fn forget_tunnel(port: u16) { + let dir = tunnel_dir(); + if !dir.is_empty() { + let _ = std::fs::remove_file(format!("{}/{}.json", dir, port)); + } +} + +/// The first trycloudflare URL in a log, if it has printed one yet. +fn quick_url(text: &str) -> String { + const TAIL: &str = ".trycloudflare.com"; + let at = match text.find(TAIL) { + Some(at) => at, + None => return String::new(), + }; + let head = &text[..at]; + let start = match head.rfind("https://") { + Some(s) => s, + None => return String::new(), + }; + let name = &head[start + 8..]; + if name.is_empty() || !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + return String::new(); + } + format!("https://{}{}", name, TAIL) +} + +/// Run a cloudflared quick tunnel for a port and return its URL. +/// +/// A quick tunnel needs no account and no DNS: cloudflared picks a random +/// trycloudflare.com name and prints it. A named tunnel on a domain of your +/// own needs credentials and a DNS record, which is a setup task rather +/// than a keypress, and is deliberately not attempted here. +fn start_tunnel(port: u16, wait: f64) -> (String, String) { + let dir = tunnel_dir(); + if dir.is_empty() { + return ( + String::new(), + "no state directory to record the tunnel in".into(), + ); + } + let log = format!("{}/{}.log", dir, port); + let handle = match std::fs::File::create(&log) { + Ok(f) => f, + Err(e) => return (String::new(), e.to_string()), + }; + let errors = match handle.try_clone() { + Ok(f) => f, + Err(e) => return (String::new(), e.to_string()), + }; + let child = std::process::Command::new("cloudflared") + .args([ + "tunnel", + "--no-autoupdate", + "--url", + &format!("http://127.0.0.1:{}", port), + ]) + .stdout(handle) + .stderr(errors) + .stdin(std::process::Stdio::null()) + .spawn(); + let child = match child { + Ok(c) => c, + Err(e) => return (String::new(), e.to_string()), + }; + let pid = child.id() as i32; + let deadline = now() + wait; + let mut found = String::new(); + while now() < deadline && found.is_empty() { + std::thread::sleep(Duration::from_millis(400)); + let text = std::fs::read_to_string(&log).unwrap_or_default(); + found = quick_url(&text); + if found.is_empty() && !alive(pid) { + break; + } + } + if found.is_empty() { + end(pid, libc::SIGTERM); + return ( + String::new(), + format!("no URL after {}s - see {}", wait as i64, log), + ); + } + let note = serde_json::json!({"pid": pid, "url": found, "port": port}); + let _ = std::fs::write(format!("{}/{}.json", dir, port), note.to_string()); + (found, String::new()) +} + +/// Whether a pid is still running. Signal 0 checks without delivering. +/// +/// A zombie answers signal 0 and is not running: it is an exit status its +/// parent has not collected yet. Reporting one as alive would leave the +/// widget offering to SIGKILL something already dead, forever, since no +/// signal moves a zombie. /proc knows the difference. +fn alive(pid: i32) -> bool { + if pid <= 0 { + return false; + } + if unsafe { libc::kill(pid, 0) } != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + return false; + } + match std::fs::read_to_string(format!("/proc/{}/stat", pid)) { + Ok(text) => match text.rsplit_once(')') { + Some((_, rest)) => rest.split_whitespace().next() != Some("Z"), + None => true, + }, + Err(_) => true, + } +} + +/// Whether this row can be signalled, and why not when it cannot. +/// +/// Only one of the two is ever set. The checks are all about not doing +/// damage past what was asked for: a row whose owner /proc would not name +/// is somebody else's process, and a process in this widget's own group +/// cannot be group-killed without taking the widget down with it. +fn killable(row: &Row) -> (Option<i32>, String) { + let pid = match row.pid { + Some(p) => p, + None => { + return ( + None, + "not yours - no owner for this socket in /proc".into(), + ) + } + }; + if pid <= 1 { + return (None, format!("refusing to signal pid {}", pid)); + } + match std::fs::metadata(format!("/proc/{}", pid)) { + Ok(meta) => { + use std::os::unix::fs::MetadataExt; + if meta.uid() != unsafe { libc::getuid() } { + return (None, format!("pid {} is not yours", pid)); + } + } + Err(_) => return (None, format!("pid {} is already gone", pid)), + } + (Some(pid), String::new()) +} + +/// Signal the process group, falling back to the process alone. +/// +/// A dev server is rarely one process: `npm run dev` is a shell, a package +/// manager and the server itself, sharing a process group precisely so that +/// Ctrl-C reaches all three. Signalling the group is what Ctrl-C does. The +/// fallback covers a process whose group we cannot read, and the guard +/// covers the case where the group is this widget's own. +fn end(pid: i32, sig: libc::c_int) -> String { + let group = unsafe { libc::getpgid(pid) }; + let ours = unsafe { libc::getpgrp() }; + let sent = if group > 0 && group != ours { + unsafe { libc::killpg(group, sig) } + } else { + unsafe { libc::kill(pid, sig) } + }; + if sent == 0 { + return String::new(); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => "already gone".into(), + Some(libc::EPERM) => "not permitted".into(), + _ => "failed".into(), + } +} + +/// Whether a command exists, so a key can say so instead of failing. +fn have(program: &str) -> bool { + tc::missing(&[program]).is_empty() +} + +// Tailscale accepts Funnel traffic on these three public ports and no +// others, so a node can have three funnels at once - not the one that +// defaulting to 443 every time would suggest. +const FUNNEL_PORTS: &[u16] = &[443, 8443, 10000]; + +/// The tailnet-side ports this node's serve config already occupies. +fn taken_ports(cfg: &serde_json::Value) -> Vec<u16> { + let mut used = Vec::new(); + for key in cfg["TCP"].as_object().into_iter().flatten().map(|(k, _)| k) { + if let Ok(port) = key.parse() { + used.push(port); + } + } + for mount in cfg["Web"].as_object().into_iter().flatten().map(|(k, _)| k) { + if let Some((_, listen)) = mount.rsplit_once(':') { + if let Ok(port) = listen.parse() { + used.push(port); + } + } + } + used +} + +/// The first public port free to funnel on, or 0 when all three are used. +fn free_funnel_port(cfg: &serde_json::Value) -> u16 { + let used = taken_ports(cfg); + FUNNEL_PORTS + .iter() + .copied() + .find(|p| !used.contains(p)) + .unwrap_or(0) +} + +/// What a subprocess said when it refused, on one line. +fn refusal(out: std::process::Output, fallback: &str) -> String { + if out.status.success() { + return String::new(); + } + let text = String::from_utf8_lossy(&out.stderr).to_string() + + &String::from_utf8_lossy(&out.stdout).to_string(); + let joined: String = text.split_whitespace().collect::<Vec<_>>().join(" "); + if joined.is_empty() { + fallback.to_string() + } else { + joined.chars().take(200).collect() + } +} + +/// Put a local port behind this node's HTTPS name. +/// +/// Serve listens on the port's own number rather than 443. Nothing forces +/// that, but 443 is where an unflagged `tailscale serve` lands, so leaving +/// it as the default would mean the second port published quietly took the +/// first one's mount. +/// +/// Funnel has only the three ports Tailscale accepts from the internet, so +/// it takes the first of them that is free - a node can hold three at once, +/// and defaulting all of them to 443 would allow one. +fn serve_port(port: u16, public: bool) -> String { + let mut listen = port; + if public { + listen = free_funnel_port(&serve_config()); + if listen == 0 { + return format!( + "all three funnel ports are in use ({}) - stop one first", + FUNNEL_PORTS + .iter() + .map(|p| p.to_string()) + .collect::<Vec<_>>() + .join(", ") + ); + } + } + let verb = if public { "funnel" } else { "serve" }; + let https = format!("--https={}", listen); + let port = port.to_string(); + match tc::run_full(&["tailscale", verb, "--bg", &https, &port], RUN_TIMEOUT) { + Ok(out) => refusal(out, "tailscale refused"), + Err(e) => e, + } +} + +/// Which tailnet-side port a local port is currently published on. +fn listen_for(cfg: &serde_json::Value, port: u16) -> u16 { + for (mount, entry) in cfg["Web"].as_object().into_iter().flatten() { + for handler in entry["Handlers"].as_object().into_iter().flatten().map(|(_, v)| v) { + if proxies_port(handler["Proxy"].as_str().unwrap_or(""), port) { + return mount + .rsplit_once(':') + .and_then(|(_, l)| l.parse().ok()) + .unwrap_or(0); + } + } + } + 0 +} + +/// Take back one port, leaving every other mount as it was. +/// +/// The mount to remove is looked up rather than assumed: it was chosen when +/// the port was published, and on a funnel that is whichever of the three +/// public ports happened to be free at the time. +/// +/// Never `serve reset`: that clears the whole configuration, including +/// whatever was already published before this widget was ever run. +fn unserve_port(port: u16, public: bool) -> String { + let mut listen = listen_for(&serve_config(), port); + if listen == 0 { + listen = if public { 443 } else { port }; + } + let verb = if public { "funnel" } else { "serve" }; + let https = format!("--https={}", listen); + match tc::run_full(&["tailscale", verb, &https, "off"], RUN_TIMEOUT) { + Ok(out) => refusal(out, "tailscale refused"), + Err(e) => e, + } +} + +/// What a kill would take down, in whatever room the pane has. +/// +/// Everything here identifies the target, but not equally: the port is the +/// one thing the person is looking at, and the framework name without the +/// project it belongs to is no use on a machine running four of them. The +/// pid is the first to go, then the kind. +fn kill_label(row: &Row, room: usize) -> String { + // An orphan's kind is the words "nothing listening", which reads badly + // in the middle of a sentence about it. The port is the whole subject. + if row.orphan { + return format!(":{}", row.port); + } + let what = if row.kind.is_empty() { + "unidentified" + } else { + &row.kind + }; + let wherein = if row.project.is_empty() { + String::new() + } else { + format!(" in {}", row.project) + }; + // Not every row has a pid. A port Tailscale serves with nothing behind + // it has none by definition, and one that exits while its screen is + // open loses the one it had - both can still be the subject of a prompt. + let who = row.pid.map_or(String::new(), |p| format!(" (pid {})", p)); + let bare = format!(":{}", row.port); + for text in [ + format!("{}{} on :{}{}", what, wherein, row.port, who), + format!("{}{} on :{}", what, wherein, row.port), + format!( + "{} on :{}", + if row.project.is_empty() { + what + } else { + &row.project + }, + row.port + ), + bare.clone(), + ] { + if text.chars().count() <= room { + return text; + } + } + bare +} + +/// A question and the key that answers it, fitted to the pane. +/// +/// On one line where both fit, on two where they do not, because the key is +/// never the half that may be truncated: a prompt with its `[y]` pushed off +/// the right edge is a prompt nobody can act on. The wording after the key +/// has shorter forms for the same reason, longest first. +fn prompt( + ask: Vec<(String, String)>, + key: (String, String), + options: &[&str], + w: usize, + dim: &str, +) -> Vec<String> { + let answer = |room: usize| -> Vec<(String, String)> { + let text = options + .iter() + .find(|o| key.1.chars().count() + o.chars().count() <= room) + .copied() + .unwrap_or(""); + vec![key.clone(), (dim.to_string(), text.to_string())] + }; + let line = |parts: &[(String, String)]| -> String { + let refs: Vec<(&str, String)> = parts + .iter() + .map(|(c, t)| (c.as_str(), t.clone())) + .collect(); + tc::seg(&refs, w - 1) + }; + // The bare last form - "[y] yes", with no word on what anything else + // does - is a fallback for a pane too narrow for two lines, not a thing + // to choose while a second line is going spare. + let keep = if options.len() > 1 { + options[options.len() - 2] + } else { + options[options.len() - 1] + }; + let asked: usize = ask.iter().map(|(_, t)| t.chars().count()).sum(); + let room = (w - 1).saturating_sub(asked + 2); + if room >= key.1.chars().count() + keep.chars().count() { + let mut parts = ask; + parts.push((dim.to_string(), " ".into())); + parts.extend(answer(room)); + return vec![line(&parts)]; + } + let mut second = vec![(dim.to_string(), " ".to_string())]; + second.extend(answer(w - 2)); + vec![line(&ask), line(&second)] +} + +/// The verb and the cost of each exposure change, for the confirmation. +fn action(kind: &str) -> (&'static str, &'static str) { + match kind { + "serve" => ("publish", "tailnet only"), + "funnel" => ("publish publicly", "anyone with the URL"), + "unserve" => ("stop serving", ""), + "unfunnel" => ("stop the funnel", ""), + "tunnel" => ("open a cloudflare tunnel", "anyone with the URL"), + "untunnel" => ("close the cloudflare tunnel", ""), + _ => ("kill", ""), + } +} + +/// Whether there is anything behind this row worth a second screen. +/// +/// A process of ours carries a command line, a directory and an age that +/// the table has no room for, and any row at all can be given an address to +/// copy or an exposure to set up. Another user's socket carries none of +/// that: the four columns already say everything /proc will tell us, and +/// opening a screen to repeat them would be a screen that wastes a press. +fn has_detail(row: &Row) -> bool { + row.pid.is_some() || row.orphan || !row.exposed.is_empty() +} + +/// Break a long value across lines at spaces, then anywhere. +fn wrap(text: &str, width: usize) -> Vec<String> { + let mut lines = Vec::new(); + let mut rest: Vec<char> = text.chars().collect(); + while !rest.is_empty() && lines.len() < 4 { + if rest.len() <= width { + lines.push(rest.iter().collect()); + break; + } + let cut = rest[..(width + 1).min(rest.len())] + .iter() + .rposition(|c| *c == ' ') + .filter(|c| *c > width / 2) + .unwrap_or(width); + lines.push(rest[..cut].iter().collect()); + rest = rest[cut..].iter().skip_while(|c| **c == ' ').copied().collect(); + } + if lines.is_empty() { + vec![String::new()] + } else { + lines + } +} + +/// One `label value` line, wrapped under its own label. +fn field(label: &str, value: &str, w: usize, colour: &str, p: &Palette) -> Vec<String> { + let label_w = 10usize; + wrap(value, ((w - 3).saturating_sub(label_w)).max(8)) + .into_iter() + .enumerate() + .map(|(i, line)| { + tc::seg( + &[ + ( + p.dim.as_str(), + format!(" {}", tc::pad(if i == 0 { label } else { "" }, label_w)), + ), + (colour, line), + ], + w - 1, + ) + }) + .collect() +} + +/// The ways this port could be published, and why one is unavailable. +/// +/// Each is a key, a name, and the state that decides whether pressing it +/// does anything. An option that cannot work says so on the line rather +/// than failing after the keypress - except Funnel, which is offered even +/// when the capability is missing, because Tailscale's own error names the +/// setting to change in the admin console better than this can. +fn expose_options( + row: &Row, + net: &Net, + tunnel: &Option<Tunnel>, +) -> Vec<(char, &'static str, String, bool)> { + let how = row.exposed.as_str(); + // One blocker outranks the others: without the operator bit every serve + // and funnel write is refused, whatever else is true of them. + let barred = if net.operator { + "" + } else { + "needs: tailscale set --operator" + }; + vec![ + ( + 's', + "tailscale serve", + if how == "tailnet" { + "serving · tailnet only".to_string() + } else if barred.is_empty() { + "tailnet only".to_string() + } else { + barred.to_string() + }, + how == "tailnet", + ), + ( + 't', + "tailscale funnel", + if how == "public" { + "public · anyone with the URL".to_string() + } else if !barred.is_empty() { + barred.to_string() + } else if net.funnel { + "public".to_string() + } else { + "not enabled for this node".to_string() + }, + how == "public", + ), + ( + 'd', + "cloudflare tunnel", + match tunnel { + Some(t) => format!("running · {}", t.url), + None if have("cloudflared") => "quick tunnel, random domain".to_string(), + None => "cloudflared not installed".to_string(), + }, + tunnel.is_some(), + ), + ] +} + +/// The second screen: everything known about one port, and what to do. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] +fn detail_rows( + row: &Row, + net: &Net, + tunnel: &Option<Tunnel>, + links: &[(String, String)], + sel: usize, + seen: &[(f64, f64, f64)], + gap: f64, + w: usize, + p: &Palette, +) -> Vec<String> { + let mut rows = vec![tc::title(&format!(":{}", row.port), w, &p.port)]; + let mut head = if !row.kind.is_empty() { + row.kind.clone() + } else if !row.user.is_empty() { + format!("{}'s", row.user) + } else { + String::new() + }; + if !row.project.is_empty() { + head += &format!(" in {}", row.project); + } + rows.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {}", head.trim())), + ( + p.dim.as_str(), + match row.up { + Some(_) => format!(" · up {}", span(row.up)), + None => String::new(), + }, + ), + ], + w - 1, + )); + rows.push(String::new()); + + if let Some(pid) = row.pid { + rows.push(tc::seg(&[(p.lbl.as_str(), " ── PROCESS ── ".into())], w - 1)); + let cmd = if row.cmdline.is_empty() { "?" } else { &row.cmdline }; + rows.extend(field("command", cmd, w, &p.txt, p)); + let cwd = if row.cwd.is_empty() { "?" } else { &row.cwd }; + let cwd_c = if row.gone { &p.warn } else { &p.txt }; + rows.extend(field("directory", cwd, w, cwd_c, p)); + let group = match unsafe { libc::getpgid(pid) } { + g if g > 0 => format!(" · group {}", g), + _ => String::new(), + }; + rows.extend(field("pid", &format!("{}{}", pid, group), w, &p.txt, p)); + rows.push(String::new()); + } + + // A lone :: is not an IPv6-only server: Linux maps IPv4 onto it unless + // the process asked for IPV6_V6ONLY, and /proc cannot say which it did. + // Claiming "IPv6 only" here would be a guess dressed as a fact. + let note = if row.families > 1 { + "two sockets, IPv4 and IPv6" + } else if row.bind == "::" { + "IPv4 too, unless the server turned that off" + } else { + "one socket" + }; + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── LISTENING ON ── ".into()), + ( + p.txt.as_str(), + if row.bind.is_empty() { + "nothing".into() + } else { + row.bind.clone() + }, + ), + (p.dim.as_str(), format!(" {}", note)), + ], + w - 1, + )); + rows.push(String::new()); + // What has actually gone through it. The kernel's own per-socket byte + // counters, summed over the connections accepted from this port - so + // this is TCP, and a port serving anything else reads as quiet. + rows.extend(traffic_chart(seen, "TRAFFIC", 3, gap, w, p)); + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── REACHABLE AT ── ".into()), + (p.dim.as_str(), "↑↓ to pick, c copies".into()), + ], + w - 1, + )); + if links.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " nothing is listening to reach".into())], + w - 1, + )); + } + for (i, (url, note)) in links.iter().enumerate() { + let here = i == sel; + rows.push(tc::seg( + &[ + ( + if here { p.accent.as_str() } else { p.dim.as_str() }, + if here { " ▸ ".into() } else { " ".into() }, + ), + ( + if here { p.txt.as_str() } else { p.dim.as_str() }, + url.clone(), + ), + (p.dim.as_str(), format!(" {}", note)), + ], + w - 1, + )); + } + rows.push(String::new()); + rows.push(tc::seg(&[(p.lbl.as_str(), " ── EXPOSE ── ".into())], w - 1)); + for (key, name, state, on) in expose_options(row, net, tunnel) { + rows.push(tc::seg( + &[ + (p.accent.as_str(), format!(" [{}] ", key)), + (p.txt.as_str(), tc::pad(name, 18)), + (if on { p.ok.as_str() } else { p.dim.as_str() }, state), + ], + w - 1, + )); + } + rows +} + +/// Something to say at the bottom of the screen until a moment passes. +type Notice = (String, String, f64); + +/// A SIGTERM that has been sent and not yet answered for. +struct Watch { + pid: i32, + row: Row, + asked: bool, + deadline: f64, +} + +/// An exposure change running on a thread, so the frame keeps drawing. +struct Working { + kind: String, + row: Row, + done: Arc<Mutex<Vec<Notice>>>, +} + +/// The second screen's own state: which port, and which address is picked. +struct Detail { + port: u16, + row: Row, + at: usize, + links: Vec<(String, String)>, + tunnel: Option<Tunnel>, +} + +/// Carry out one exposure change and record how it went. +fn start_work(kind: &str, row: Row) -> Working { + let done = Arc::new(Mutex::new(Vec::new())); + let job = Arc::clone(&done); + let (what, port) = (kind.to_string(), row.port); + let p = rgb_ok(); + std::thread::spawn(move || { + let said: Notice = match what.as_str() { + "serve" | "funnel" => { + let failed = serve_port(port, what == "funnel"); + if failed.is_empty() { + ( + format!("{} now serves :{}", what, port), + p.ok, + now() + 6.0, + ) + } else { + (failed, p.bad, now() + 8.0) + } + } + "unserve" | "unfunnel" => { + let failed = unserve_port(port, what == "unfunnel"); + if failed.is_empty() { + (format!("stopped serving :{}", port), p.ok, now() + 5.0) + } else { + (failed, p.bad, now() + 8.0) + } + } + "tunnel" => { + let (url, failed) = start_tunnel(port, 25.0); + if failed.is_empty() { + (url, p.ok, now() + 20.0) + } else { + (failed, p.bad, now() + 10.0) + } + } + _ => { + if let Some(t) = tunnel_state(port) { + end(t.pid, libc::SIGTERM); + forget_tunnel(port); + } + ( + format!("closed the tunnel on :{}", port), + p.ok, + now() + 5.0, + ) + } + }; + if let Ok(mut guard) = job.lock() { + guard.push(said); + } + }); + Working { + kind: kind.to_string(), + row, + done, + } +} + +/// The bottom of either screen. +/// +/// One of five things, in the order they matter: a question that must be +/// answered before anything happens, the wait after answering it, a slow +/// action still running, the outcome of the last one, or the ordinary keys. +fn footer( + confirm: &Option<(String, Row)>, + watch: &Option<Watch>, + working: &Option<Working>, + notice: &Option<Notice>, + w: usize, + hints: &[Vec<(String, String)>], + p: &Palette, +) -> Vec<String> { + if let Some((kind, row)) = confirm { + let (verb, cost) = action(kind); + let mut ask = vec![ + (p.bad.clone(), format!(" {} ", verb)), + ( + p.txt.clone(), + kill_label(row, w.saturating_sub(34 + verb.len())), + ), + ]; + if !cost.is_empty() { + ask.push((p.warn.clone(), format!(" - {}", cost))); + } + ask.push((p.dim.clone(), "?".into())); + return prompt( + ask, + (p.warn.clone(), "[y]".into()), + &[ + " yes · any other key cancels", + " yes · any key cancels", + " yes", + ], + w, + &p.dim, + ); + } + if let Some(state) = watch { + if state.asked { + return prompt( + vec![ + (p.warn.clone(), " still up: ".into()), + (p.txt.clone(), kill_label(&state.row, w - 30)), + ], + (p.bad.clone(), "[f]".into()), + &[ + " force kill · any other key leaves it", + " SIGKILL · any key leaves it", + " SIGKILL", + ], + w, + &p.dim, + ); + } + return vec![tc::seg( + &[ + (p.dim.as_str(), " SIGTERM sent, waiting for ".into()), + (p.txt.as_str(), kill_label(&state.row, w - 29)), + ], + w - 1, + )]; + } + if let Some(job) = working { + let verb = action(&job.kind).0; + return vec![tc::seg( + &[( + p.warn.as_str(), + format!(" {} :{} - this can take a moment", verb, job.row.port), + )], + w - 1, + )]; + } + if let Some((text, colour, _)) = notice { + return vec![tc::seg(&[(colour.as_str(), format!(" {}", text))], w - 1)]; + } + let refs: Vec<Vec<(&str, String)>> = hints + .iter() + .map(|group| { + group + .iter() + .map(|(c, t)| (c.as_str(), t.clone())) + .collect() + }) + .collect(); + tc::pack_hints(&refs, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect() +} + +struct Store { + rows: Mutex<Vec<Row>>, + /// Why the poller stopped, if it did. A caught panic used to be thrown + /// away here: the scan returned an empty list and the table drew as if + /// nothing were listening, which is the one thing this widget must + /// never say by accident. + err: Mutex<String>, + /// What has moved on each listening port, sampled by the same poll that + /// finds the ports. One `ss` call per scan rather than a second thread: + /// a thread that dies is invisible, and this needs no finer resolution + /// than the table it sits under. + traffic: Mutex<Traffic>, + // [r] asks for a scan now rather than at the end of the interval, and + // so does anything that has just changed what a scan would find. + wake: (Mutex<bool>, Condvar), +} + +impl Store { + fn wake(&self) { + if let Ok(mut asked) = self.wake.0.lock() { + *asked = true; + self.wake.1.notify_all(); + } + } +} + +fn main() { + tc::maybe_help(include_str!("ports_help.txt")); + // Both of ports' config keys were documented and read by nobody. + // Config is the default; argv still overrides. + let cfg = tc::load_config("ports"); + if let Some(listed) = configured_system_ports(&cfg) { + let _ = CONFIGURED_PORTS.set(listed); + } + let mut refresh = tc::poll_secs(tc::cfg_f64(&cfg, "refresh", 4.0), 4.0).max(1.0); + let args: Vec<String> = std::env::args().skip(1).collect(); + let mut i = 0; + while i < args.len() { + if (args[i] == "-n" || args[i] == "--refresh") && i + 1 < args.len() { + refresh = tc::poll_secs(args[i + 1].parse().unwrap_or(4.0), 4.0).max(1.0); + i += 2; + } else { + i += 1; + } + } + + let ok = rgb_ok(); + let store = Arc::new(Store { + rows: Mutex::new(Vec::new()), + err: Mutex::new(String::new()), + traffic: Mutex::new(Traffic::default()), + wake: (Mutex::new(false), Condvar::new()), + }); + let poller = Arc::clone(&store); + std::thread::spawn(move || loop { + // A thread that dies takes its explanation with it, so the scan is + // caught - but catching it is only half of the rule. It used to end + // in `unwrap_or_default()`, which handed the table an empty list and + // drew a machine with nothing listening. The reason goes on screen + // and the thread stops, the way usage and herdr-panes do it. + let found = match std::panic::catch_unwind(scan) { + Ok(found) => found, + Err(_) => { + let why = "poller stopped - see the pane it was started from"; + if let Ok(mut guard) = poller.err.lock() { + *guard = why.into(); + } + return; + } + }; + // The ports to tally against, taken from the scan that just ran, so + // a port that has just appeared is measured from its next sample + // rather than never. + let listening: Vec<u16> = found.iter().filter(|r| !r.gone).map(|r| r.port).collect(); + let counters = match std::panic::catch_unwind(|| run(&["ss", "-tine"])) { + Ok(text) => text, + Err(_) => { + let why = "traffic poller stopped - the table below is still current"; + if let Ok(mut guard) = poller.err.lock() { + *guard = why.into(); + } + // Traffic is one column of many; the ports themselves are + // still being found, so this one says so and carries on. + String::new() + } + }; + if let Ok(mut guard) = poller.rows.lock() { + *guard = found; + } + if let Ok(mut guard) = poller.traffic.lock() { + guard.sample(&counters, &listening, now()); + } + let (lock, cond) = &poller.wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(refresh)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + *asked = false; + }); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let (mut selected, mut hide_system, mut scroll) = (0usize, true, 0usize); + // The five things that can be happening besides the list: a question + // waiting on a key, the pause after a SIGTERM, a slow action on a + // thread, the outcome of the last one, and the second screen. + let mut confirm: Option<(String, Row)> = None; + let mut watch: Option<Watch> = None; + let mut working: Option<Working> = None; + let mut notice: Option<Notice> = None; + let mut detail: Option<Detail> = None; + // Tailscale is asked once per visit to the second screen rather than + // once per frame: two subprocesses at 3Hz would cost more than the + // whole rest of the widget. Any change made there clears them. + let mut net: Option<(Net, serde_json::Value)> = None; + + loop { + let (w, h) = tc::size(); + + // A SIGTERM is given a moment to work before the harder question is + // asked, because most things do stop. + if let Some(state) = watch.as_mut() { + if !state.asked && (!alive(state.pid) || now() >= state.deadline) { + if !alive(state.pid) { + notice = Some(( + format!("stopped {}", kill_label(&state.row, w - 12)), + ok.ok.clone(), + now() + 4.0, + )); + watch = None; + store.wake(); + } else { + state.asked = true; + } + } + } + + // An action that talks to tailscaled or cloudflared takes seconds, + // which is far too long to hold a frame for, so it runs on a thread + // and its answer is collected here. + if let Some(job) = working.as_ref() { + let done = job.done.lock().ok().and_then(|g| g.first().cloned()); + if let Some(said) = done { + notice = Some(said); + working = None; + net = None; + store.wake(); + } + } + + for key in keyboard.poll() { + // Only an explicit yes acts. Every other key cancels, + // deliberately including q: quitting must never double as + // consent to signal something or publish it. + if let Some((kind, row)) = confirm.take() { + if key != "y" && key != "Y" { + notice = Some(("cancelled".into(), ok.dim.clone(), now() + 2.0)); + continue; + } + if kind == "kill" { + let (pid, why) = killable(&row); + let pid = match pid { + Some(p) => p, + None => { + notice = Some((why, ok.bad.clone(), now() + 5.0)); + continue; + } + }; + let failed = end(pid, libc::SIGTERM); + if failed.is_empty() { + watch = Some(Watch { + pid, + row, + asked: false, + deadline: now() + 3.0, + }); + } else { + notice = Some(( + format!( + "{}: {}", + kill_label(&row, w.saturating_sub(4 + failed.len())), + failed + ), + ok.bad.clone(), + now() + 5.0, + )); + store.wake(); + } + } else if working.is_none() { + working = Some(start_work(&kind, row)); + } + continue; + } + if watch.as_ref().is_some_and(|s| s.asked) { + let state = watch.take().expect("just checked"); + if key == "f" || key == "F" { + let failed = end(state.pid, libc::SIGKILL); + notice = Some(if failed.is_empty() { + ( + format!("SIGKILL sent to {}", kill_label(&state.row, w - 19)), + ok.warn.clone(), + now() + 5.0, + ) + } else { + ( + format!( + "{}: {}", + kill_label(&state.row, w.saturating_sub(4 + failed.len())), + failed + ), + ok.bad.clone(), + now() + 5.0, + ) + }); + } else { + notice = Some(( + format!("left running: {}", kill_label(&state.row, w - 17)), + ok.dim.clone(), + now() + 3.0, + )); + } + store.wake(); + continue; + } + // The second screen keeps its own selection - of addresses + // rather than rows - and hands every other key back. + if let Some(view) = detail.as_mut() { + match key.as_str() { + // Left and esc come out; q quits, which is what the + // footer beside it says and what q does everywhere + // else. backspace is gone: an alias no hint named. + "esc" | "left" => { + detail = None; + } + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "up" => view.at = view.at.saturating_sub(1), + "down" => view.at += 1, + "c" | "C" => { + if !view.links.is_empty() { + let url = &view.links[view.at.min(view.links.len() - 1)].0; + // The address goes in the notice either way: + // OSC 52 is refused by some terminals and + // swallowed by some multiplexers, and a copy + // that silently did nothing would leave nothing + // on screen to read instead. + let copied = tc::clipboard(url); + notice = Some(( + format!( + "{}{}", + if copied { "copied " } else { "no clipboard " }, + url + ), + if copied { ok.ok.clone() } else { ok.warn.clone() }, + now() + 8.0, + )); + } + } + "s" | "S" | "t" | "T" | "d" | "D" => { + let mut kind = match key.to_lowercase().as_str() { + "s" => "serve", + "t" => "funnel", + _ => "tunnel", + }; + let how = view.row.exposed.as_str(); + if kind == "serve" && how == "tailnet" { + kind = "unserve"; + } else if kind == "funnel" && how == "public" { + kind = "unfunnel"; + } else if kind == "tunnel" && view.tunnel.is_some() { + kind = "untunnel"; + } else if kind == "tunnel" && !have("cloudflared") { + notice = Some(( + "cloudflared is not installed - see the docs \ + for the one-line install" + .into(), + ok.warn.clone(), + now() + 8.0, + )); + continue; + } + if working.is_none() { + confirm = Some((kind.to_string(), view.row.clone())); + } + } + "r" | "R" => { + net = None; + store.wake(); + } + _ => {} + } + continue; + } + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "up" => selected = selected.saturating_sub(1), + "down" => selected += 1, + "o" | "O" => hide_system = !hide_system, + "r" | "R" => store.wake(), + "enter" | "right" => { + let all: Vec<Row> = store.rows.lock().map(|g| g.clone()).unwrap_or_default(); + let shown: Vec<Row> = all + .into_iter() + .filter(|r| !(hide_system && theirs(r))) + .collect(); + if let Some(row) = shown.get(selected.min(shown.len().saturating_sub(1))) { + if has_detail(row) { + detail = Some(Detail { + port: row.port, + row: row.clone(), + at: 0, + links: Vec::new(), + tunnel: None, + }); + net = None; + } else { + notice = Some(( + "nothing more to show - /proc will not name \ + another user's process" + .into(), + ok.dim.clone(), + now() + 5.0, + )); + } + } + } + "k" | "K" => { + if watch.is_none() { + let all: Vec<Row> = + store.rows.lock().map(|g| g.clone()).unwrap_or_default(); + let shown: Vec<Row> = all + .into_iter() + .filter(|r| !(hide_system && theirs(r))) + .collect(); + if let Some(row) = shown.get(selected.min(shown.len().saturating_sub(1))) { + let (pid, why) = killable(row); + if pid.is_some() { + confirm = Some(("kill".into(), row.clone())); + } else { + notice = Some((why, ok.bad.clone(), now() + 5.0)); + } + } + } + } + _ => {} + } + } + + // Rebuilt after the keys rather than before them, so that a press of + // o is answered in the frame it was made in and not the next one. + let all: Vec<Row> = store.rows.lock().map(|g| g.clone()).unwrap_or_default(); + let shown: Vec<&Row> = all + .iter() + .filter(|r| !(hide_system && theirs(r))) + .collect(); + if !shown.is_empty() && selected >= shown.len() { + selected = shown.len() - 1; + } + if notice.as_ref().is_some_and(|n| now() >= n.2) { + notice = None; + } + + if let Some(view) = detail.as_mut() { + if net.is_none() { + net = Some((tailnet_self(), serve_config())); + } + let (self_node, cfg) = net.as_ref().expect("just filled"); + match all.iter().find(|r| r.port == view.port) { + Some(live) => view.row = live.clone(), + None => { + view.row.pid = None; + view.row.gone = true; + } + } + view.tunnel = tunnel_state(view.port); + view.links = addresses(&view.row, self_node, cfg); + if let Some(t) = view.tunnel.as_ref() { + view.links + .push((t.url.clone(), "public · cloudflare".to_string())); + } + view.at = view.at.min(view.links.len().saturating_sub(1)); + let seen = store + .traffic + .lock() + .map(|t| t.series(view.port)) + .unwrap_or_default(); + let mut rows = detail_rows( + &view.row, + self_node, + &view.tunnel, + &view.links, + view.at, + &seen, + refresh, + w, + &ok, + ); + let foot = footer( + &confirm, + &watch, + &working, + ¬ice, + w, + &[ + vec![(ok.accent.clone(), "↑↓".into()), (ok.dim.clone(), " address".into())], + vec![(ok.dim.clone(), "[c]opy".into())], + vec![(ok.dim.clone(), "[s]erve".into())], + vec![(ok.dim.clone(), "[t]unnel".into())], + vec![(ok.dim.clone(), "[d] cloudflare".into())], + vec![ + (ok.accent.clone(), "←".into()), + (ok.dim.clone(), "/esc back".into()), + ], + vec![(ok.dim.clone(), "[q]uit".into())], + ], + &ok, + ); + let room = h.saturating_sub(foot.len() + 1); + rows.truncate(room); + while rows.len() < room { + rows.push(String::new()); + } + rows.extend(foot); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(300)); + continue; + } + + let mine = all.iter().filter(|r| r.pid.is_some()).count(); + let off_box = all.iter().filter(|r| !r.exposed.is_empty()).count(); + + let mut rows = vec![tc::title("dev servers", w, &ok.port)]; + rows.push(tc::seg( + &[ + (ok.dim.as_str(), format!(" {} listening", all.len())), + (ok.dim.as_str(), format!(" · {} yours", mine)), + (ok.dim.as_str(), " · ".into()), + ( + if off_box > 0 { &ok.ok } else { &ok.dim }, + format!("{} reachable off-box", off_box), + ), + (ok.dim.as_str(), format!(" every {}s", refresh as i64)), + ], + w - 1, + )); + // A dead poller says so, right under the counts it has stopped + // updating. Same line, same shape and same words as herdr-panes and + // usage, so it reads the same wherever you meet it. + let err = store.err.lock().map(|g| g.clone()).unwrap_or_default(); + if !err.is_empty() { + rows.push(tc::seg(&[(ok.bad.as_str(), format!(" ! {}", err))], w - 1)); + } + rows.push(String::new()); + + let wide = w >= 78; + let rates = store.traffic.lock().ok(); + // Everything moving through every listening port, over time. The + // table is what this widget is for, so the chart yields to it: it is + // drawn only when there are rows to spare after the table, the + // header and the footer have taken theirs. + let totals = rates.as_ref().map(|t| t.totals()).unwrap_or_default(); + let spare = h + .saturating_sub(rows.len() + 3 + shown.len().min(12) + 1) + .min(3); + if spare >= 3 && totals.iter().any(|(u, d, _)| *u > 0.0 || *d > 0.0) { + rows.extend(traffic_chart( + &totals, + "EVERYTHING MOVING", + spare / 2 + spare % 2, + refresh, + w, + &ok, + )); + rows.push(String::new()); + } + // The project column takes whatever the fixed ones leave: it is the + // one that identifies the server, and the one whose contents are a + // directory name of any length. + // The WHAT column takes the widest name it has to show, plus a gap. + // It used to be a flat eighteen with nothing after it, so a name of + // exactly that length ran straight into the project and the two read + // as one word - and anything longer was cut, which names a different + // program. Sized to the whole list rather than the visible slice, so + // the columns do not shift as it scrolls. + // The traffic column is the first to go and the last to arrive: it + // is the only one here not about what the port *is*. It arrives when + // there is room for it *after* the names, rather than past some + // number of columns picked in advance - the project column is the + // one that gives, and a project's name cut in half is a different + // project. + let traffic_w = 13usize; + // The shape column is the more decorative of the two, so it arrives + // after the rates and leaves before them. + let spark_cells = 14usize; + let widest_project = shown + .iter() + .map(|r| { + if r.project.is_empty() { &r.user } else { &r.project }.chars().count() + }) + .max() + .unwrap_or(8) + .clamp(8, 24); + let (fits, shapes) = extra_columns( + w, + longest_kind(&shown), + widest_project, + traffic_w, + spark_cells + 4, + ); + let busy = rates.is_some() && fits; + let sparks = rates.is_some() && shapes; + let traffic_w = if busy { traffic_w } else { 0 }; + // Two cells of gap either side, the same as every other column here. + let spark_w = if sparks { spark_cells + 4 } else { 0 }; + // Named from the samples actually shown, so it stays true while the + // history is still filling. + let spark_head = rates + .as_ref() + .map(|t| { + let deepest = shown + .iter() + .map(|r| t.series(r.port)) + .map(|s| { + s.iter().rev().take(spark_cells).map(|x| x.2).sum::<f64>() + }) + .fold(0.0f64, f64::max); + if deepest > 0.0 { + format!("LAST {}", over(deepest)) + } else { + "SHAPE".to_string() + } + }) + .unwrap_or_else(|| "SHAPE".to_string()); + let rest = 1 + 6 + 8 + 2 + 8 + traffic_w + spark_w + if wide { 6 + 8 } else { 0 }; + let kind_w = longest_kind(&shown).min((w - 1).saturating_sub(rest).max(4)); + let fixed = 1 + 6 + 8 + kind_w + 2 + traffic_w + spark_w + if wide { 6 + 8 } else { 0 }; + let name_w = std::cmp::max(8, (w - 1).saturating_sub(fixed)); + rows.push(tc::seg( + &[ + (ok.dim.as_str(), " PORT BIND ".into()), + (ok.dim.as_str(), format!("{} ", tc::pad("WHAT", kind_w))), + (ok.dim.as_str(), tc::pad("PROJECT", name_w)), + ( + ok.dim.as_str(), + if busy { tc::pad("TRAFFIC", traffic_w) } else { String::new() }, + ), + // The window the shapes cover, named rather than left to be + // guessed at - a sparkline without one is a shape. + ( + ok.dim.as_str(), + // The same two-cell gap the cells carry, so the heading + // sits over the shapes rather than two left of them. + if sparks { + format!(" {} ", tc::pad(&spark_head, spark_cells)) + } else { + String::new() + }, + ), + ( + ok.dim.as_str(), + if wide { "UP EXPOSED".into() } else { String::new() }, + ), + ], + w - 1, + )); + + let visible = std::cmp::max(1, h.saturating_sub(rows.len() + 3)); + if selected < scroll { + scroll = selected; + } else if selected >= scroll + visible { + scroll = selected - visible + 1; + } + scroll = std::cmp::min(scroll, shown.len().saturating_sub(visible)); + + for (i, row) in shown.iter().enumerate().skip(scroll).take(visible) { + let here = i == selected; + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let (note, note_colour) = bind_note(row, &ok); + // Another user's row names its owner rather than its project, + // which it has none of that we can read. That is the whole of + // what is knowable about it. + let who = if !row.project.is_empty() { + row.project.clone() + } else if !row.user.is_empty() { + row.user.clone() + } else if row.pid.is_some() { + "—".into() + } else { + String::new() + }; + let port_colour = format!("{}{}", tint, if here { &ok.accent } else { &ok.port }); + let note_c = format!("{}{}", tint, note_colour); + let kind_c = format!( + "{}{}", + tint, + if row.guessed || row.kind.is_empty() { + &ok.dim + } else { + &ok.txt + } + ); + let who_c = format!( + "{}{}", + tint, + if row.gone { + &ok.warn + } else if !row.user.is_empty() { + &ok.dim + } else { + &ok.txt + } + ); + // Nothing rather than "0 B/s" on a quiet port: a column of + // zeroes down the table reads as a measurement that has failed, + // and this one is just a port nobody is calling. + let (up, down) = rates + .as_ref() + .and_then(|t| t.rate(row.port)) + .unwrap_or((0.0, 0.0)); + let moving = up > 0.0 || down > 0.0; + // Each direction only when it has moved. A port serving a + // download reads "↑820K", not "↑820K ↓0B" - the second half + // would be four cells saying nothing happened. + let traffic_text = if busy && moving { + [(up, "↑"), (down, "↓")] + .into_iter() + .filter(|(v, _)| *v > 0.0) + .map(|(v, arrow)| format!("{}{}", arrow, brief(v))) + .collect::<Vec<_>>() + .join(" ") + } else { + String::new() + }; + let traffic_c = format!("{}{}", tint, if moving { &ok.open } else { &ok.dim }); + // Built before the row, because every segment's colour has to + // outlive the borrows the row is assembled from. + let shape: Vec<(String, String)> = if sparks { + let mut out = vec![(format!("{}{}", tint, ok.dim), " ".to_string())]; + let seen = rates.as_ref().map(|t| t.series(row.port)).unwrap_or_default(); + for (colour, text) in spark(&seen, spark_cells, &ok) { + out.push((format!("{}{}", tint, colour), text)); + } + out.push((format!("{}{}", tint, ok.dim), " ".to_string())); + out + } else { + Vec::new() + }; + let mut line = vec![ + ( + port_colour.as_str(), + format!("{}{:<6}", if here { "▸" } else { " " }, row.port), + ), + (note_c.as_str(), format!("{:<8}", note)), + (kind_c.as_str(), format!("{} ", tc::pad(&row.kind, kind_w))), + (who_c.as_str(), tc::pad(&who, name_w)), + ]; + if busy { + line.push((traffic_c.as_str(), tc::pad(&traffic_text, traffic_w))); + } + for (colour, text) in &shape { + line.push((colour.as_str(), text.clone())); + } + let up_c = format!("{}{}", tint, ok.dim); + let exp_c = format!( + "{}{}", + tint, + match row.exposed.as_str() { + "tailnet" => &ok.ok, + "public" => &ok.bad, + _ => &ok.grid, + } + ); + if wide { + line.push((up_c.as_str(), format!("{:<6}", span(row.up)))); + line.push(( + exp_c.as_str(), + if row.exposed.is_empty() { + "-".into() + } else { + row.exposed.clone() + }, + )); + } + if here { + line.push((tint.as_str(), " ".repeat(w))); + } + rows.push(tc::seg(&line, w - 1)); + } + + let foot = footer( + &confirm, + &watch, + &working, + ¬ice, + w, + &[ + vec![(ok.accent.clone(), "↑↓".into()), (ok.dim.clone(), " select".into())], + vec![ + (ok.accent.clone(), "→/↵".into()), + (ok.dim.clone(), " details".into()), + ], + vec![(ok.dim.clone(), "[k]ill".into())], + vec![( + ok.dim.clone(), + format!("[o]{} system", if hide_system { "show" } else { "hide" }), + )], + vec![(ok.dim.clone(), "[r]efresh".into())], + vec![(ok.dim.clone(), "[q]uit".into())], + ], + &ok, + ); + while rows.len() < h.saturating_sub(foot.len() + 1) { + rows.push(String::new()); + } + rows.extend(foot); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(300)); + } +} + +struct Palette { + ok: String, + lbl: String, + warn: String, + bad: String, + dim: String, + grid: String, + txt: String, + accent: String, + port: String, + open: String, + local: String, +} + +fn rgb_ok() -> Palette { + Palette { + ok: tc::rgb(90, 240, 160), + lbl: tc::rgb(130, 165, 200), + warn: tc::rgb(255, 200, 90), + bad: tc::rgb(255, 100, 110), + dim: tc::rgb(127, 147, 172), + grid: tc::rgb(60, 78, 98), + txt: tc::rgb(225, 235, 245), + accent: tc::rgb(150, 210, 255), + port: tc::rgb(160, 220, 255), + open: tc::rgb(255, 170, 120), + local: tc::rgb(120, 200, 160), + } +} + +/// What the bound address means for who can reach it. +/// +/// The address itself is too wide for the column - a tailnet IPv6 address +/// is 24 characters - and is rarely the answer to the question being asked. +fn bind_note(row: &Row, p: &Palette) -> (String, String) { + if row.orphan { + return ("--".into(), p.dim.clone()); + } + let reach = bind_class(&row.bind); + match reach.as_str() { + "all" => ("all".into(), p.open.clone()), + "local" => ("local".into(), p.local.clone()), + "tailnet" => ("tailnet".into(), p.accent.clone()), + other => (other.chars().take(7).collect(), p.txt.clone()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Two sockets in the shape `ss -tine` prints them: the addresses and + /// the inode on one line, the counters on an indented continuation. + fn ss_dump(sent_a: u64, sent_b: u64, ino_b: &str) -> String { + format!( + "State Recv-Q Send-Q Local Address:Port Peer Address:Port\n\ + ESTAB 0 0 192.0.2.7:3000 192.0.2.9:51234 ino:111 sk:1\n\ + \t ts sack cubic bytes_sent:{} bytes_received:40 segs_out:9\n\ + ESTAB 0 0 192.0.2.7:9999 192.0.2.9:51235 ino:{} sk:2\n\ + \t ts sack cubic bytes_sent:{} bytes_received:70 segs_out:9\n", + sent_a, ino_b, sent_b + ) + } + + #[test] + fn a_sockets_counters_are_read_off_its_continuation_line() { + let got = socket_counters(&ss_dump(1000, 2000, "222")); + assert_eq!(got.len(), 2); + // The *local* port, because that is the one a listener is on. + assert_eq!(got["111"], Counters { port: 3000, sent: 1000, recv: 40 }); + assert_eq!(got["222"], Counters { port: 9999, sent: 2000, recv: 70 }); + } + + #[test] + fn only_a_socket_seen_twice_can_say_what_moved() { + let before = socket_counters(&ss_dump(1000, 2000, "222")); + let after = socket_counters(&ss_dump(1500, 2000, "222")); + let moved_now = moved(&before, &after, &[3000, 9999]); + assert_eq!(moved_now.get(&3000), Some(&(500, 0))); + assert_eq!(moved_now.get(&9999), Some(&(0, 0))); + + // A socket with no previous reading contributes nothing rather than + // its whole lifetime total in one sample. Stated on a port that + // *also* has a socket carrying over, because a new connection to a + // busy port is when this actually happens - and because asserting + // it on an empty result would pass whether or not it were true. + let mut fresh = after.clone(); + fresh.insert( + "333".into(), + Counters { port: 3000, sent: 8_000_000, recv: 8_000_000 }, + ); + let moved_now = moved(&before, &fresh, &[3000, 9999]); + assert_eq!( + moved_now.get(&3000), + Some(&(500, 0)), + "a connection seen for the first time reported its whole life as one sample" + ); + + // Ports nothing is listening on are not tallied at all. Most + // established sockets are outbound and their local port is an + // ephemeral number that belongs to nothing. + let moved_now = moved(&before, &after, &[3000]); + assert!(!moved_now.contains_key(&9999)); + } + + #[test] + fn a_reused_inode_does_not_report_a_lifetime_as_one_sample() { + // Inodes come back after a socket closes. The new socket's counters + // start again, so subtracting the old ones is meaningless - and + // subtracting a larger number from a smaller one would wrap. + let before = socket_counters(&ss_dump(1000, 9_000_000, "222")); + // Same inode, now on a different port: a different socket. + let after = socket_counters(&ss_dump(1000, 5, "222")) + .into_iter() + .map(|(k, mut c)| { + if k == "222" { + c.port = 4444; + } + (k, c) + }) + .collect(); + let moved_now = moved(&before, &after, &[3000, 4444, 9999]); + assert_eq!(moved_now.get(&4444), None, "a reused inode was counted"); + + // And on the same port, a counter that went backwards clamps rather + // than wrapping to sixteen exabytes. + let after = socket_counters(&ss_dump(1000, 5, "222")); + let moved_now = moved(&before, &after, &[3000, 9999]); + assert_eq!(moved_now.get(&9999), Some(&(0, 0))); + } + + #[test] + fn a_rate_is_measured_against_the_gap_that_actually_happened() { + // [r] polls early. A rate divided by the nominal interval would + // read high every time somebody pressed it. + let mut t = Traffic::default(); + t.sample(&ss_dump(1000, 0, "222"), &[3000], 100.0); + // The first sample has nothing to subtract from and so is not a + // reading at all. + assert_eq!(t.rate(3000), None); + assert!(t.series(3000).is_empty()); + + // Two seconds later, a thousand bytes: five hundred a second. + t.sample(&ss_dump(3000, 0, "222"), &[3000], 102.0); + assert_eq!(t.rate(3000), Some((1000.0, 0.0))); + // Half a second later, the same thousand: two thousand a second. + t.sample(&ss_dump(4000, 0, "222"), &[3000], 102.5); + assert_eq!(t.rate(3000), Some((2000.0, 0.0))); + // Each sample carries the gap it was measured over, so a chart of + // the last few can say how much history they are. + let seen = t.series(3000); + assert_eq!(seen.len(), 2); + assert_eq!(seen.iter().map(|s| s.2).sum::<f64>(), 2.5); + } + + #[test] + fn a_wider_pane_never_shows_fewer_columns_than_a_narrower_one() { + // TRAFFIC used to arrive at seventy-six and leave again at + // seventy-eight, where UP and EXPOSED turn up and take the room - + // one fact traded for another as the pane grew. + let (kind_w, project_w, traffic_w, spark_w) = (23usize, 22usize, 13usize, 18usize); + let mut had = (false, false); + for w in 40..220usize { + let now = extra_columns(w, kind_w, project_w, traffic_w, spark_w); + assert!( + now.0 >= had.0 && now.1 >= had.1, + "w={} lost a column the narrower pane had: {:?} then {:?}", + w, had, now + ); + // The shapes are the more decorative of the two and never + // arrive on their own. + assert!(!now.1 || now.0, "shapes without rates at w={}", w); + had = now; + } + // Both ends: nothing in a narrow pane, both in a wide one. + assert_eq!(extra_columns(60, kind_w, project_w, traffic_w, spark_w), (false, false)); + assert_eq!(extra_columns(210, kind_w, project_w, traffic_w, spark_w), (true, true)); + } + + #[test] + fn the_totals_ring_has_one_entry_per_sample_whether_or_not_anything_moved() { + let mut t = Traffic::default(); + t.sample(&ss_dump(1000, 0, "222"), &[3000, 9999], 100.0); + // The first sample is not a reading, here as anywhere. + assert!(t.totals().is_empty()); + + // Quiet second. + t.sample(&ss_dump(1000, 0, "222"), &[3000, 9999], 101.0); + // Busy second, on both ports at once. + t.sample(&ss_dump(1500, 400, "222"), &[3000, 9999], 102.0); + let totals = t.totals(); + assert_eq!(totals.len(), 2, "a quiet sample still takes a column"); + assert_eq!(totals[0].0, 0.0); + // Summed across ports: five hundred on one, four hundred on the + // other, in one second. + assert_eq!(totals[1].0, 900.0); + + // Bounded like the per-port rings, or a chart that keeps one column + // per sample grows for as long as the widget is up. + for i in 0..(TRAFFIC_KEPT + 20) { + t.sample(&ss_dump(2000 + i as u64, 0, "222"), &[3000], 200.0 + i as f64); + } + assert_eq!(t.totals().len(), TRAFFIC_KEPT); + } + + #[test] + fn a_sparkline_keeps_unmeasured_and_quiet_apart() { + let p = rgb_ok(); + let plain = |cells: Vec<(String, String)>| -> String { + cells.into_iter().map(|(_, t)| t).collect() + }; + + // Nothing sampled at all: every cell dotted. + assert_eq!(plain(spark(&[], 6, &p)), "······"); + + // Sampled and quiet: a baseline, which is a different thing and has + // to look like one - the port's own screen makes the same + // distinction one keypress away. + let quiet: Vec<(f64, f64, f64)> = (0..6).map(|_| (0.0, 0.0, 1.0)).collect(); + assert_eq!(plain(spark(&quiet, 6, &p)), "──────"); + + // Partly sampled: dots for what is missing, then the rest. + let some: Vec<(f64, f64, f64)> = (0..2).map(|_| (0.0, 0.0, 1.0)).collect(); + assert_eq!(plain(spark(&some, 6, &p)), "········".chars().take(4).collect::<String>() + "──"); + + // Scaled to its own peak, not to some other row's: the biggest + // sample here is full height whatever its absolute size. + let small: Vec<(f64, f64, f64)> = vec![(1.0, 0.0, 1.0), (8.0, 0.0, 1.0)]; + let big: Vec<(f64, f64, f64)> = vec![(1e6, 0.0, 1.0), (8e6, 0.0, 1.0)]; + assert_eq!(plain(spark(&small, 2, &p)), plain(spark(&big, 2, &p))); + assert!(plain(spark(&small, 2, &p)).ends_with('█'), "the peak is full height"); + } + + #[test] + fn a_port_that_stops_listening_is_forgotten_and_the_ring_is_bounded() { + let mut t = Traffic::default(); + for i in 0..(TRAFFIC_KEPT + 40) { + t.sample(&ss_dump(i as u64 * 10, 0, "222"), &[3000], 100.0 + i as f64); + } + assert_eq!(t.series(3000).len(), TRAFFIC_KEPT); + // Nothing listens on it any more: its history goes with it rather + // than growing for as long as the widget is up. + t.sample(&ss_dump(9999, 0, "222"), &[], 500.0); + assert!(t.series(3000).is_empty()); + assert_eq!(t.rate(3000), None); + } + + #[test] + fn a_binary_living_under_a_runtimes_path_is_not_that_runtime() { + // Through `kind_of`, not the helper alone: an earlier version of + // this test passed with the table still calling `contains`, which + // is the bug it was written for. + // + // A real one from this machine: a standalone browser binary that + // npm installed into node_modules. It is not written in Node and + // the widget said "Node". + let under = "/home/u/.nvm/versions/node/v24.18.0/lib/node_modules/agent-browser/bin/agent-browser-linux-x64"; + assert_eq!(kind_of(under, 37397).0, "agent-browser-linux-x64"); + assert_eq!(kind_of("/srv/python-tools/bin/collector", 9000).0, "collector"); + + // The runtimes themselves still answer to their names. + assert_eq!(kind_of("/usr/bin/node server.js", 3000).0, "Node"); + assert_eq!(kind_of("/usr/bin/python3 app.py", 8000).0, "Python"); + + // And the specific frameworks still win over the runtime, which is + // the order the table is written in. + assert_eq!(kind_of("node /app/node_modules/.bin/vite", 5173).0, "Vite"); + } + + #[test] + fn a_runtime_is_named_only_when_it_is_the_thing_being_run() { + // The command really is the runtime. + assert!(runs_command("/usr/bin/node server.js", "node")); + assert!(runs_command("node server.js", "node")); + assert!(runs_command("/usr/bin/python3 app.py", "python")); + assert!(runs_command("/usr/bin/python3.11 -m http.server", "python")); + + // A directory on the way to something else is not. This is the + // whole bug: a standalone binary installed under node_modules was + // listed as "Node", the runtime it is not written in. + assert!(!runs_command("/usr/lib/node_modules/x/bin/mytool", "node")); + assert!(!runs_command("/home/u/.nvm/versions/node/v24/bin/agent-browser-linux-x64", "node")); + assert!(!runs_command("/srv/python-tools/bin/collector", "python")); + // And a name that merely starts the same way is a different name. + assert!(!runs_command("/usr/bin/nodemon app.js", "node")); + assert!(!runs_command("/usr/bin/python-config", "python")); + } + + #[test] + fn ipv6_comes_out_of_little_endian_words() { + // ::1 as /proc/net/tcp6 writes it: four words, each reversed. + assert_eq!(hex_addr("00000000000000000000000001000000"), "::1"); + assert_eq!(hex_addr("00000000000000000000000000000000"), "::"); + // A tailnet address, taken from this machine's own /proc/net/tcp6 + // and checked against what ss prints for the same socket, rather + // than assembled by hand - the first attempt at that was wrong in + // a way that made a correct decoder look broken. + assert_eq!( + hex_addr("5C117AFD0000E0A100000000686338DE"), + "fd7a:115c:a1e0::de38:6368" + ); + } + + #[test] + fn ipv4_comes_out_of_little_endian_hex() { + // 0100007F is 127.0.0.1 the way /proc writes it. + assert_eq!(hex_addr("0100007F"), "127.0.0.1"); + assert_eq!(hex_addr("00000000"), "0.0.0.0"); + } + + #[test] + fn reachability_is_classified_not_printed() { + assert_eq!(bind_class("0.0.0.0"), "all"); + assert_eq!(bind_class("::"), "all"); + assert_eq!(bind_class("127.0.0.1"), "local"); + assert_eq!(bind_class("::1"), "local"); + assert_eq!(bind_class("100.64.0.102"), "tailnet"); + assert_eq!(bind_class("fd7a:115c:a1e0::1"), "tailnet"); + // A LAN address is its own answer, not one of the three. + assert_eq!(bind_class("192.168.1.9"), "192.168.1.9"); + assert_eq!(bind_class("10.0.0.46"), "10.0.0.46"); + } + + #[test] + fn tailnet_range_stops_where_it_should() { + assert!(is_tailnet_v4("100.64.0.1")); + assert!(is_tailnet_v4("100.127.255.254")); + assert!(!is_tailnet_v4("100.63.0.1")); + assert!(!is_tailnet_v4("100.128.0.1")); + } + + #[test] + fn a_version_in_the_title_is_kept() { + let (kind, guessed) = kind_of("next-server (v16.3.1)", 3000); + assert_eq!(kind, "Next.js 16.3.1"); + assert!(!guessed); + } + + #[test] + fn a_port_number_alone_is_marked_as_a_guess() { + let (kind, guessed) = kind_of("", 443); + assert_eq!(kind, "HTTPS?"); + assert!(guessed, "a guess from a port number must say so"); + } + + #[test] + fn proxy_lines_give_up_their_ports() { + let json = r#"{"Web":{"host:443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:4100"}}}}}"#; + assert_eq!(proxied_ports(json), vec![4100]); + } + + #[test] + fn a_package_name_beats_the_directory() { + assert_eq!( + json_string(r#"{"name": "piaf-web", "version": "1.0"}"#, "name"), + Some("piaf-web".into()) + ); + } + + #[test] + fn a_proxy_target_names_a_whole_port() { + // The Python matches `:3000` or `:3000/` and not `:30001`. A plain + // substring search would report a served port that is not served, + // and the detail screen would offer a URL that answers nothing. + assert!(proxies_port("http://127.0.0.1:3000", 3000)); + assert!(proxies_port("http://127.0.0.1:3000/", 3000)); + assert!(proxies_port("http://127.0.0.1:3000/app", 3000)); + assert!(!proxies_port("http://127.0.0.1:30001", 3000)); + assert!(!proxies_port("http://127.0.0.1:13000", 3000)); + } + + #[test] + fn the_serve_url_carries_its_mount() { + let cfg: serde_json::Value = serde_json::from_str( + r#"{"Web": {"host.ts.net:443": {"Handlers": {"/": + {"Proxy": "http://127.0.0.1:3003"}}}}}"#, + ) + .unwrap(); + // 443 is the default and is left off; anything else is spelled out. + assert_eq!(served_url(&cfg, 3003), "https://host.ts.net/"); + assert_eq!(served_url(&cfg, 3004), ""); + let other: serde_json::Value = serde_json::from_str( + r#"{"Web": {"host.ts.net:8443": {"Handlers": {"/": + {"Proxy": "http://127.0.0.1:3003"}}}}}"#, + ) + .unwrap(); + assert_eq!(served_url(&other, 3003), "https://host.ts.net:8443/"); + assert_eq!(listen_for(&other, 3003), 8443); + } + + #[test] + fn a_funnel_takes_the_first_port_that_is_free() { + // Tailscale accepts funnel traffic on three ports, so a node can + // hold three at once. Defaulting to 443 every time would allow one. + let cfg: serde_json::Value = + serde_json::from_str(r#"{"Web": {"host.ts.net:443": {"Handlers": {}}}}"#).unwrap(); + assert_eq!(free_funnel_port(&cfg), 8443); + let full: serde_json::Value = + serde_json::from_str(r#"{"Web": {"h:443": {}, "h:8443": {}, "h:10000": {}}}"#).unwrap(); + assert_eq!(free_funnel_port(&full), 0); + } + + #[test] + fn a_kill_prompt_gives_up_its_parts_in_order() { + let row = Row { + port: 3000, + kind: "Next.js 16.3.1".into(), + project: "piaf-web".into(), + pid: Some(4242), + ..Default::default() + }; + let full = "Next.js 16.3.1 in piaf-web on :3000 (pid 4242)"; + assert_eq!(kill_label(&row, 99), full); + // The pid is the first thing to go, then the kind - the port is the + // one thing the person is actually looking at. + assert_eq!( + kill_label(&row, full.len() - 1), + "Next.js 16.3.1 in piaf-web on :3000" + ); + assert_eq!(kill_label(&row, 25), "piaf-web on :3000"); + assert_eq!(kill_label(&row, 8), ":3000"); + // An orphan's kind is the words "nothing listening", which reads + // badly in the middle of a sentence about it. + let orphan = Row { + port: 4100, + orphan: true, + ..Default::default() + }; + assert_eq!(kill_label(&orphan, 99), ":4100"); + } + + #[test] + fn a_long_value_wraps_at_a_space_when_there_is_one() { + assert_eq!(wrap("one two three", 7), vec!["one two", "three"]); + // A word with no break in it is cut rather than dropped. + assert_eq!(wrap("abcdefghij", 4), vec!["abcd", "efgh", "ij"]); + assert_eq!(wrap("", 8), vec![""]); + } + + #[test] + fn only_a_row_with_something_behind_it_opens() { + assert!(has_detail(&Row { + pid: Some(7), + ..Default::default() + })); + assert!(has_detail(&Row { + orphan: true, + ..Default::default() + })); + assert!(has_detail(&Row { + exposed: "tailnet".into(), + ..Default::default() + })); + // Another user's socket: the four columns already say everything + // /proc will tell us, so a second screen would waste the press. + assert!(!has_detail(&Row { + user: "root".into(), + ..Default::default() + })); + } + + #[test] + fn a_quick_tunnel_url_is_read_out_of_the_log() { + let log = "INF +--------------------------------------+\n\ + INF | https://calm-fox-runs.trycloudflare.com |\n"; + assert_eq!(quick_url(log), "https://calm-fox-runs.trycloudflare.com"); + assert_eq!(quick_url("INF starting tunnel"), ""); + } + + #[test] + fn an_empty_system_port_list_is_not_the_defaults() { + // Absent means the built-in list. Present and empty means hide + // nothing. The two used to collapse because `[]` was read as + // unset, so `"system_ports": []` kept hiding 22 and 53. + assert!(configured_system_ports(&serde_json::json!({})).is_none()); + assert_eq!( + configured_system_ports(&serde_json::json!({"system_ports": []})), + Some(vec![]) + ); + // A number that is not a port is dropped, not wrapped. 65558 as + // u16 is 22, and hiding SSH for a typo is worse than ignoring it. + assert_eq!( + configured_system_ports(&serde_json::json!({"system_ports": [22, 65558, 65535]})), + Some(vec![22, 65535]) + ); + } + + #[test] + fn spans_read_as_a_person_would_say_them() { + assert_eq!(span(Some(45.0)), "45s"); + // The unit changes at the unit, not half again past it. + assert_eq!(span(Some(90.0)), "1m"); + assert_eq!(span(Some(5000.0)), "1h"); + assert_eq!(span(Some(600.0)), "10m"); + assert_eq!(span(Some(7200.0)), "2h"); + assert_eq!(span(Some(200_000.0)), "2d"); + assert_eq!(span(None), "--"); + } +} diff --git a/widgets/src/bin/ports_help.txt b/widgets/src/bin/ports_help.txt new file mode 100644 index 0000000..b5369c9 --- /dev/null +++ b/widgets/src/bin/ports_help.txt @@ -0,0 +1,34 @@ +What is listening on this machine, what started it, and who can reach it. + +On a box running several agents at once, "which port is that project on" and +"is this reachable from outside" are asked constantly and answered badly. +`lsof -i` gives a pid and a port and stops there. + +Each row is one listening service: the port, what it is bound to, what kind of +server it is, the project directory it was started from - the label that +actually identifies a dev server - how long it has been up, and whether +anything outside this machine can reach it. A server listening on both IPv4 +and IPv6 is one row, not two. + + ports [-n SECONDS] + +Read from /proc alone: the socket table for the ports, and each process's own +cmdline and cwd for the rest. Exposure comes from `tailscale serve status` +where Tailscale is installed. Another user's sockets cannot be tied to a +process without root, so those rows name the owner the socket table gives +and are hidden behind o along with the system ports. + +k stops the selected server, after a confirmation and only for a process you +own: SIGTERM to its process group, which is what Ctrl-C in its own terminal +would have sent, then the offer of SIGKILL if it is still up three seconds +later. + +Enter opens a second screen for one port, where there is more to show than the +table holds: the command behind it, every address it can actually be reached +at - bounded by what the socket is bound to - and c to copy one. From there s +and t publish it over Tailscale, to the tailnet or to the internet, and d +opens a Cloudflare quick tunnel. Each asks first. + +Keys: up/down select, enter opens, esc goes back, c copies, s serves, +t funnels, d tunnels, k kills, o hides the machine's own ports, r refreshes, +q quits. diff --git a/widgets/src/bin/pr.rs b/widgets/src/bin/pr.rs new file mode 100644 index 0000000..b02abc8 --- /dev/null +++ b/widgets/src/bin/pr.rs @@ -0,0 +1,2288 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Every open pull request you can see, and what is holding each one up. +//! +//! A port of pr.py. GitHub's search has no OR, so anything that is a union +//! of conditions is several searches merged; each source remembers which +//! PRs it found, so narrowing to one costs no request. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::{NaiveDateTime, Utc}; +use toys_core as tc; + +const API: &str = "https://api.github.com/graphql"; +const SORTS: &[&str] = &["updated", "created"]; +const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const SPARK: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +/// Width of the opened-per-day chart. +const OPENED_DAYS: i64 = 30; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// The GitHub token, shared with github.py rather than duplicated. +fn token(pr_cfg: &serde_json::Value, gh_cfg: &serde_json::Value) -> (String, &'static str) { + for cfg in [pr_cfg, gh_cfg] { + let value = tc::cfg_str(cfg, "token", ""); + if !value.is_empty() { + return (value, "config"); + } + } + let name = tc::cfg_str(pr_cfg, "token_env", "GITHUB_TOKEN"); + let name = if name.is_empty() { "GITHUB_TOKEN".into() } else { name }; + match std::env::var(&name) { + Ok(value) if !value.is_empty() => (value, "env"), + _ => (String::new(), "missing"), + } +} + +#[derive(Clone, Copy, Default)] +struct Rate { + remaining: Option<i64>, +} + +fn graphql( + query: &str, + tok: &str, + variables: serde_json::Value, +) -> Result<serde_json::Value, String> { + let body = serde_json::json!({ "query": query, "variables": variables }).to_string(); + let (out, _headers) = tc::post_json( + API, + &[ + ("Authorization", &format!("Bearer {}", tok)), + ("Content-Type", "application/json"), + ("User-Agent", "terminal-toys"), + ], + &body, + 45, + )?; + let data: serde_json::Value = serde_json::from_str(&out).map_err(|e| e.to_string())?; + if let Some(first) = data["errors"].as_array().and_then(|a| a.first()) { + return Err(first["message"] + .as_str() + .unwrap_or("") + .chars() + .take(100) + .collect()); + } + Ok(data["data"].clone()) +} + +const PR_FIELDS: &str = " + number title url isDraft createdAt updatedAt + additions deletions changedFiles + author { login } + repository { nameWithOwner } + headRefName baseRefName reviewDecision mergeable + stackEntry { position stack { number size } } + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +/// One request, one aliased search per source. +/// +/// The ceiling is on result nodes rather than field complexity: three +/// searches of 100 return HTTP 502 with or without the check rollup, three +/// of 50 do not. So the page size is per source and deliberately modest. +fn list_query(queries: &[String], limit: usize) -> String { + let mut parts = vec!["rateLimit { remaining }".to_string()]; + for (i, q) in queries.iter().enumerate() { + parts.push(format!( + "s{}: search(query: {}, type: ISSUE, first: {}) {{ issueCount nodes {{ ... on PullRequest {{ {} }} }} }}", + i, + serde_json::Value::String(q.clone()), + limit, + PR_FIELDS + )); + } + format!("{{ {} }}", parts.join(" ")) +} + +const DETAIL_QUERY: &str = r#" +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + number title url state isDraft createdAt updatedAt + additions deletions changedFiles + author { login } headRefName baseRefName + mergeable mergeStateStatus reviewDecision + commitCount: commits { totalCount } + stack { number size baseRefName + entries(first: 40) { nodes { position pullRequest { + number title isDraft reviewDecision mergeable + additions deletions author { login } headRefName } } } } + reviewThreads(first: 60) { nodes { isResolved } } + reviews(last: 20) { nodes { author { login } state submittedAt } } + reviewRequests(first: 12) { nodes { requestedReviewer { + ... on User { login } ... on Team { name } } } } + commits(last: 1) { nodes { commit { statusCheckRollup { + state contexts(first: 25) { totalCount nodes { + ... on CheckRun { name conclusion status startedAt completedAt } + ... on StatusContext { context state } } } } } } } + } + } +}"#; + +/// Every open PR in one repository, for reconstructing a stack that was not +/// made with `gh stack` - the API's own stack field is authoritative when +/// it is there, and null everywhere else. +const REPO_PRS_QUERY: &str = r#" +query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 100) { + nodes { number title isDraft headRefName baseRefName + additions deletions author { login } + reviewDecision mergeable } + } + } +}"#; + +fn text(value: &serde_json::Value, key: &str) -> String { + value[key].as_str().unwrap_or("").to_string() +} + +fn number(value: &serde_json::Value, key: &str) -> i64 { + value[key].as_i64().unwrap_or(0) +} + +fn parse(ts: &str) -> Option<NaiveDateTime> { + if ts.len() < 19 { + return None; + } + NaiveDateTime::parse_from_str(&ts[..19], "%Y-%m-%dT%H:%M:%S").ok() +} + +fn hours_since(ts: &str) -> Option<f64> { + let when = parse(ts)?; + Some((Utc::now().naive_utc() - when).num_seconds() as f64 / 3600.0) +} + +fn ago(ts: &str) -> String { + let Some(hours) = hours_since(ts) else { + return "--".into(); + }; + let s = hours * 3600.0; + if s < 3600.0 { + format!("{}m", ((s / 60.0) as i64).max(1)) + } else if s < 86400.0 { + format!("{}h", (s / 3600.0) as i64) + } else if s < 86400.0 * 365.0 { + format!("{}d", (s / 86400.0) as i64) + } else { + format!("{:.1}y", s / (86400.0 * 365.0)) + } +} + +fn span(hours: Option<f64>) -> String { + let Some(h) = hours else { + return "--".into(); + }; + if h < 48.0 { + return format!("{}h", h as i64); + } + let days = h / 24.0; + if days < 365.0 { + format!("{}d", days as i64) + } else { + format!("{:.1}y", days / 365.0) + } +} + +fn rollup(pr: &serde_json::Value) -> String { + pr["commits"]["nodes"] + .as_array() + .and_then(|a| a.first()) + .map(|n| text(&n["commit"]["statusCheckRollup"], "state")) + .unwrap_or_default() +} + +/// Approved, green, no conflict, not a draft - the actionable count. +/// +/// Everything else on this board describes work in flight; this is the one +/// number that says something can be done right now. +fn ready_to_merge(pr: &serde_json::Value) -> bool { + let checks = rollup(pr); + text(pr, "reviewDecision") == "APPROVED" + && (checks == "SUCCESS" || checks.is_empty()) + && text(pr, "mergeable") != "CONFLICTING" + && !pr["isDraft"].as_bool().unwrap_or(false) +} + +/// The chain this PR belongs to, reconstructed from branch names. +/// +/// A PR whose base branch is another open PR's head branch is sitting on +/// top of it. That inference produces a tree rather than a line, so each PR +/// keeps its list of children. +type Chain = (Option<i64>, HashMap<i64, i64>, HashMap<i64, Vec<i64>>); + +fn stack_of(number: i64, repo_prs: &[serde_json::Value]) -> Chain { + let mut by_head: HashMap<String, i64> = HashMap::new(); + for other in repo_prs { + by_head.insert(text(other, "headRefName"), self::number(other, "number")); + } + let mut parent: HashMap<i64, i64> = HashMap::new(); + let mut kids: HashMap<i64, Vec<i64>> = HashMap::new(); + for other in repo_prs { + let mine = self::number(other, "number"); + if let Some(up) = by_head.get(&text(other, "baseRefName")) { + if *up != mine { + parent.insert(mine, *up); + kids.entry(*up).or_default().push(mine); + } + } + } + if !parent.contains_key(&number) && !kids.contains_key(&number) { + return (None, HashMap::new(), HashMap::new()); + } + let mut root = number; + let mut seen: HashSet<i64> = HashSet::new(); + while let Some(up) = parent.get(&root) { + if !seen.insert(root) { + break; + } + root = *up; + } + (Some(root), parent, kids) +} + +/// A row of the stack map: connector, the PR, whether it is the open one, +/// and its position when GitHub gave one. +type StackRow = (String, serde_json::Value, bool, Option<i64>); + +/// What the open is actually doing, so the wait can show real work. +#[derive(Clone)] +struct Stage { + label: String, + done: bool, + t0: f64, + took: f64, +} + +#[derive(Default)] +struct State { + viewer: String, + orgs: Vec<String>, + prs: Vec<serde_json::Value>, + total: usize, + /// Set when a source filled its page, so `total` is a floor and every + /// count drawn from `prs` describes what was fetched rather than what + /// is open. + capped: bool, + query: String, + detail: Option<serde_json::Value>, + stack_rows: Vec<StackRow>, + want: Option<(String, String, i64)>, + loading: bool, + target: String, + stages: Vec<Stage>, + err: String, + fetched: f64, +} + +impl State { + fn stage(&mut self, label: &str, done: bool) -> f64 { + if let Some(st) = self.stages.iter_mut().find(|s| s.label == label) { + st.done = done; + st.took = now() - st.t0; + return st.t0; + } + let t0 = now(); + self.stages.push(Stage { + label: label.to_string(), + done, + t0, + took: 0.0, + }); + t0 + } +} + +/// Each configured source, with `@mine` expanded and args appended. +/// +/// Repeated qualifiers of the same kind are OR'd by GitHub, so one search +/// covers every org and your own account at once; relationships that reach +/// outside them - authored, assigned - need their own. +fn searches( + sources: &[(String, String)], + viewer: &str, + orgs: &[String], + extra: &[String], +) -> Vec<(String, String)> { + let mut mine: Vec<String> = orgs.iter().map(|o| format!("org:{}", o)).collect(); + if !viewer.is_empty() { + mine.push(format!("user:{}", viewer)); + } + let mine = mine.join(" "); + sources + .iter() + .map(|(name, q)| { + let mut parts = vec![q.replace("@mine", &mine)]; + parts.extend(extra.iter().cloned()); + (name.clone(), parts.join(" ")) + }) + .collect() +} + +fn fetch_detail( + tok: &str, + want: &(String, String, i64), + state: &Arc<Mutex<State>>, +) -> Result<(), String> { + let (owner, name, num) = want; + let t0 = state + .lock() + .map(|mut g| g.stage("pull request, checks, reviews", false)) + .unwrap_or(0.0); + let d = graphql( + DETAIL_QUERY, + tok, + serde_json::json!({ "owner": owner, "name": name, "number": num }), + )?; + if let Ok(mut g) = state.lock() { + g.stage("pull request, checks, reviews", true); + let _ = t0; + } + let pr = d["repository"]["pullRequest"].clone(); + let mut rows: Vec<StackRow> = Vec::new(); + if !pr.is_null() { + let native = &pr["stack"]; + if !native.is_null() { + if let Ok(mut g) = state.lock() { + g.stage("stack, from GitHub", true); + } + // GitHub hands the order over directly, position 1 nearest the + // base. A native stack is a line, not a tree, so it draws flat - + // eleven levels of indentation would be unreadable and would + // imply a branching that is not there. + let mut entries: Vec<serde_json::Value> = native["entries"]["nodes"] + .as_array() + .cloned() + .unwrap_or_default(); + entries.sort_by_key(|e| number(e, "position")); + let last = entries.len().saturating_sub(1); + for (i, e) in entries.iter().enumerate() { + let child = e["pullRequest"].clone(); + let twig = if i == last { "└─ " } else { "├─ " }; + let is_here = number(&child, "number") == *num; + let position = e["position"].as_i64(); + rows.push((twig.to_string(), child, is_here, position)); + } + } else { + if let Ok(mut g) = state.lock() { + g.stage("stack, from open branches", false); + } + let repo = graphql( + REPO_PRS_QUERY, + tok, + serde_json::json!({ "owner": owner, "name": name }), + )?; + if let Ok(mut g) = state.lock() { + g.stage("stack, from open branches", true); + } + let others: Vec<serde_json::Value> = repo["repository"]["pullRequests"]["nodes"] + .as_array() + .cloned() + .unwrap_or_default(); + let (root, _parent, kids) = stack_of(*num, &others); + if let Some(root) = root { + let by_num: HashMap<i64, &serde_json::Value> = + others.iter().map(|o| (number(o, "number"), o)).collect(); + // An inferred stack really is a tree - one PR can have two + // others branched off it - so the connectors are drawn + // properly rather than indenting by depth alone. + let mut stack = vec![(root, String::new(), true)]; + while let Some((num_at, prefix, last)) = stack.pop() { + if let Some(node) = by_num.get(&num_at) { + rows.push(( + format!("{}{}", prefix, if last { "└─ " } else { "├─ " }), + (*node).clone(), + num_at == *num, + None, + )); + } + let mut children = kids.get(&num_at).cloned().unwrap_or_default(); + children.sort_unstable(); + let below = format!("{}{}", prefix, if last { " " } else { "│ " }); + for (i, kid) in children.iter().enumerate().rev() { + stack.push((*kid, below.clone(), i + 1 == children.len())); + } + } + } + } + } + if let Ok(mut g) = state.lock() { + g.detail = if pr.is_null() { None } else { Some(pr) }; + g.stack_rows = rows; + g.loading = false; + } + Ok(()) +} + +/// How many open pull requests the pooled sources really cover, and whether +/// that number is a floor rather than a count. +/// +/// A source that filled its page has more behind it, and the sources +/// overlap - `orgs` and `authored` find the same PR all day - so the +/// `issueCount`s cannot be added up. Two things are known exactly: the union +/// holds every distinct PR already in hand, and it holds the whole of any +/// single source, so it is no smaller than the largest `issueCount`. The +/// larger of those is the floor the header reports. When nothing filled its +/// page the pool *is* the union and the number is a plain total. +fn union_total(sources: &[(i64, usize)], pooled: usize) -> (usize, bool) { + let capped = sources.iter().any(|(counted, got)| *counted > *got as i64); + if !capped { + return (pooled, false); + } + let floor = sources + .iter() + .map(|(counted, _)| (*counted).max(0) as usize) + .fold(pooled, usize::max); + (floor, true) +} + +fn fetch_list( + tok: &str, + source: &str, + sources: &[(String, String)], + extra: &[String], + limit: usize, + state: &Arc<Mutex<State>>, + rate: &Arc<Mutex<Rate>>, +) -> Result<(), String> { + let need_viewer = state.lock().map(|g| g.viewer.is_empty()).unwrap_or(true); + if need_viewer { + // Every org, not the first page of them. `@mine` is built out of + // this list as owner qualifiers, so an org missing here is not an + // undercount - it is a scope the board never searched, and nothing + // downstream can tell that from an org with no open PRs. + let mut login = String::new(); + let mut orgs: Vec<String> = Vec::new(); + let mut cursor: Option<String> = None; + loop { + let who = graphql( + "query($after: String) { viewer { login \ + organizations(first: 100, after: $after) { \ + pageInfo { hasNextPage endCursor } nodes { login } } } }", + tok, + serde_json::json!({ "after": cursor }), + )?; + if login.is_empty() { + login = text(&who["viewer"], "login"); + } + let conn = &who["viewer"]["organizations"]; + orgs.extend( + conn["nodes"] + .as_array() + .into_iter() + .flatten() + .map(|o| text(o, "login")) + .filter(|o| !o.is_empty()), + ); + let next = conn["pageInfo"]["endCursor"] + .as_str() + .unwrap_or("") + .to_string(); + // This runs on the poller thread: a cursor that stops advancing + // has to end the loop rather than spin it. + if !conn["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false) + || next.is_empty() + || Some(&next) == cursor.as_ref() + { + break; + } + cursor = Some(next); + } + if let Ok(mut g) = state.lock() { + g.viewer = login; + g.orgs = orgs; + } + } + let (viewer, orgs) = state + .lock() + .map(|g| (g.viewer.clone(), g.orgs.clone())) + .unwrap_or_default(); + let pairs = searches(sources, &viewer, &orgs, extra); + let queries: Vec<String> = pairs.iter().map(|(_, q)| q.clone()).collect(); + let d = graphql(&list_query(&queries, limit), tok, serde_json::json!({}))?; + if let Some(left) = d["rateLimit"]["remaining"].as_i64() { + if let Ok(mut g) = rate.lock() { + g.remaining = Some(left); + } + } + // Pool the sources, remembering which found each PR and noting when a + // source filled its page, so a truncated union is not read as a total. + let mut pool: HashMap<String, serde_json::Value> = HashMap::new(); + let mut order: Vec<String> = Vec::new(); + let mut counted: Vec<(i64, usize)> = Vec::new(); + for (i, (name, _)) in pairs.iter().enumerate() { + let block = &d[format!("s{}", i)]; + let got: Vec<&serde_json::Value> = block["nodes"] + .as_array() + .into_iter() + .flatten() + .filter(|n| !n.is_null()) + .collect(); + // What the search says it matched, beside what it handed over. + counted.push((block["issueCount"].as_i64().unwrap_or(0), got.len())); + for n in got { + let url = text(n, "url"); + let entry = pool.entry(url.clone()).or_insert_with(|| { + order.push(url.clone()); + let mut copy = n.clone(); + copy["sources"] = serde_json::Value::Array(Vec::new()); + copy + }); + if let Some(list) = entry["sources"].as_array_mut() { + list.push(serde_json::Value::String(name.clone())); + } + } + } + let nodes: Vec<serde_json::Value> = order + .into_iter() + .filter_map(|url| pool.remove(&url)) + .collect(); + if let Ok(mut g) = state.lock() { + g.query = pairs + .iter() + .map(|(n, _)| n.clone()) + .collect::<Vec<_>>() + .join(", "); + let (total, capped) = union_total(&counted, nodes.len()); + g.total = total; + g.capped = capped; + g.prs = nodes; + g.fetched = now(); + g.err = if source == "config" { + tc::config_token_warning().unwrap_or_default() + } else { + String::new() + }; + } + Ok(()) +} + +struct Palette { + ok: String, + warn: String, + bad: String, + bad_lit: String, + dim: String, + /// A colour to draw over the selected-row tint. + /// + /// `dim` is 3.81 against `bg(38, 56, 76)`, under the 4.5 CLAUDE.md asks for + /// against the tint as well as the background. This is the same grey lifted + /// until it clears - 4.94 - and it is used *only* where a tint is on, so an + /// untinted row is exactly the colour it always was. Not quite the same as + /// "unselected": herdr-panes tints a blocked or done row whether or not it + /// is selected, and those get the lighter colours too. + /// + /// The substitution happens inside the closure that composes the tint, not + /// at each call site. Seventeen sites were counted when this was found and + /// there were twenty-three by the time it was fixed; more than half of them + /// reach `dim` through a condition that has nothing to do with selection - + /// `if count > 0 { loud } else { dim }` - and a zero count is the normal + /// state, so those are the common case rather than the rare one. Anyone + /// fixing this a call site at a time would fix the obvious half. + dim_lit: String, + grid: String, + txt: String, + lbl: String, + accent: String, + pr: String, +} + +fn palette() -> Palette { + Palette { + ok: tc::rgb(90, 240, 160), + warn: tc::rgb(255, 200, 90), + bad: tc::rgb(255, 100, 110), + bad_lit: tc::rgb(255, 128, 136), + dim: tc::rgb(127, 147, 172), + dim_lit: tc::rgb(140, 170, 195), + grid: tc::rgb(60, 78, 98), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + accent: tc::rgb(150, 210, 255), + pr: tc::rgb(180, 160, 255), + } +} + +fn review_label<'a>(decision: &str, p: &'a Palette) -> (&'static str, &'a str) { + match decision { + "APPROVED" => ("approved", &p.ok), + "CHANGES_REQUESTED" => ("changes", &p.bad), + "REVIEW_REQUIRED" => ("needs review", &p.warn), + _ => ("—", &p.dim), + } +} + +fn check_label<'a>(state: &str, p: &'a Palette) -> (&'static str, &'a str) { + match state { + "SUCCESS" => ("pass", &p.ok), + "FAILURE" | "ERROR" => ("FAIL", &p.bad), + "PENDING" => ("running", &p.warn), + "EXPECTED" => ("waiting", &p.dim), + _ => ("—", &p.dim), + } +} + +fn merge_label<'a>(state: &str, p: &'a Palette) -> (&'static str, &'a str) { + match state { + "CLEAN" => ("ready", &p.ok), + "DIRTY" => ("CONFLICT", &p.bad), + "BLOCKED" => ("blocked", &p.warn), + "BEHIND" => ("behind", &p.warn), + "UNSTABLE" => ("checks failing", &p.warn), + // HAS_HOOKS means a merge queue or a required hook stands between + // this and the button - the PR itself is mergeable. pr.py has always + // called it ready; the port called it "checking", which reads as + // "not finished yet" and is the opposite of what it means. + "HAS_HOOKS" => ("ready", &p.ok), + // Drafts had no case and fell to the em-dash, so a draft was + // indistinguishable from a PR whose state GitHub had not sent. + "DRAFT" => ("draft", &p.dim), + "UNKNOWN" => ("checking", &p.dim), + _ => ("—", &p.dim), + } +} + +fn matches(pr: &serde_json::Value, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + let hay = [ + number(pr, "number").to_string(), + text(pr, "title"), + text(&pr["author"], "login"), + text(&pr["repository"], "nameWithOwner"), + text(pr, "headRefName"), + text(pr, "baseRefName"), + ] + .join(" ") + .to_lowercase(); + hay.contains(&needle.to_lowercase()) +} + +fn sort_prs(prs: &[serde_json::Value], field: &str, newest_first: bool) -> Vec<serde_json::Value> { + let key = if field == "updated" { "updatedAt" } else { "createdAt" }; + let mut out = prs.to_vec(); + out.sort_by(|a, b| { + let (x, y) = (text(a, key), text(b, key)); + if newest_first { y.cmp(&x) } else { x.cmp(&y) } + }); + out +} + +fn main() { + tc::maybe_help(include_str!("pr_help.txt")); + let cfg = tc::load_config("pr"); + let gh = tc::load_config("github"); + let mut refresh = tc::poll_secs(tc::cfg_f64(&cfg, "refresh", 60.0), 60.0); + let limit = tc::cfg_usize(&cfg, "limit", 50); + // GitHub search has no OR, so anything that is a union of conditions has + // to be several searches merged. + let sources: Vec<(String, String)> = match cfg.get("sources").and_then(|v| v.as_object()) { + Some(map) => map + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) + .collect(), + None => vec![ + ("orgs".into(), "is:open is:pr @mine".into()), + ("authored".into(), "is:open is:pr author:@me".into()), + ("assigned".into(), "is:open is:pr assignee:@me".into()), + ], + }; + + let args: Vec<String> = std::env::args().skip(1).collect(); + let mut extra: Vec<String> = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-n" | "--refresh" if i + 1 < args.len() => { + refresh = tc::poll_secs(args[i + 1].parse().unwrap_or(60.0), 60.0); + i += 2; + } + other if !other.starts_with('-') => { + extra.push(other.to_string()); + i += 1; + } + _ => i += 1, + } + } + + let absent = tc::missing(&["curl"]); + if !absent.is_empty() { + tc::cannot_start( + "pr watch", + &absent, + &[ + "Everything here comes from GitHub's GraphQL API, and curl is", + "how this reaches it - the same way the other widgets reach", + "ss, ping and tailscale.", + "", + "The token is passed to curl on its standard input rather than", + "in its arguments, because /proc/<pid>/cmdline is readable by", + "every user on the machine.", + ], + "apt install curl", + ); + return; + } + + let p = palette(); + let state = Arc::new(Mutex::new(State::default())); + let rate = Arc::new(Mutex::new(Rate::default())); + let wake = Arc::new((Mutex::new(false), Condvar::new())); + let (tok, source) = token(&cfg, &gh); + + let poller = Arc::clone(&state); + let poller_wake = Arc::clone(&wake); + let poller_rate = Arc::clone(&rate); + let poll_tok = tok.clone(); + let poll_sources = sources.clone(); + let poll_extra = extra.clone(); + std::thread::spawn(move || loop { + if poll_tok.is_empty() { + if let Ok(mut g) = poller.lock() { + g.err = "no token: set github.token in config.json or $GITHUB_TOKEN".into(); + } + } else { + let want = poller.lock().ok().and_then(|g| { + let have = g.detail.as_ref().map(|d| number(d, "number")); + match &g.want { + Some(w) if Some(w.2) != have => Some(w.clone()), + _ => None, + } + }); + let mut failed = None; + if let Some(want) = want { + if let Err(said) = fetch_detail(&poll_tok, &want, &poller) { + failed = Some(said); + } + } + if failed.is_none() { + if let Err(said) = fetch_list( + &poll_tok, + source, + &poll_sources, + &poll_extra, + limit, + &poller, + &poller_rate, + ) { + failed = Some(said); + } + } + if let Some(said) = failed { + if let Ok(mut g) = poller.lock() { + g.err = said; + g.loading = false; + } + } + } + let (lock, cond) = &*poller_wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(refresh)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + *asked = false; + }); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let (mut selected, mut tick, mut stack_sel) = (0usize, 0usize, 0usize); + let mut sort_at = 0usize; + let mut newest_first = true; + let (mut needle, mut typing) = (String::new(), false); + let mut show_stats = true; + let mut copied: (String, f64) = (String::new(), 0.0); + let mut source_filter = "all".to_string(); + let filter_names: Vec<String> = std::iter::once("all".to_string()) + .chain(sources.iter().map(|(n, _)| n.clone())) + .collect(); + + let nudge = |wake: &Arc<(Mutex<bool>, Condvar)>| { + let (lock, cond) = &**wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + }; + + loop { + tick += 1; + let (prs, total, capped, detail, stack_rows, loading, err, fetched, stages, target) = + match state.lock() { + Ok(g) => ( + g.prs.clone(), + g.total, + g.capped, + g.detail.clone(), + g.stack_rows.clone(), + g.loading, + g.err.clone(), + g.fetched, + g.stages.clone(), + g.target.clone(), + ), + Err(_) => return, + }; + let shown: Vec<serde_json::Value> = sort_prs(&prs, SORTS[sort_at], newest_first) + .into_iter() + .filter(|pr| matches(pr, &needle)) + .filter(|pr| { + source_filter == "all" + || pr["sources"] + .as_array() + .into_iter() + .flatten() + .any(|s| s.as_str() == Some(source_filter.as_str())) + }) + .collect(); + + for key in keyboard.poll() { + if typing { + // While filtering, keys are text - only escape and enter are + // navigation, or the filter could never contain "q". + match key.as_str() { + "esc" => { + needle.clear(); + typing = false; + } + "enter" => typing = false, + "backspace" => { + needle.pop(); + } + other if other.chars().count() == 1 => needle.push_str(other), + _ => {} + } + continue; + } + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "/" => typing = true, + // Left comes out of the detail the way it does everywhere + // else. esc keeps its second job of clearing the search when + // there is no detail open; left has no business doing that, + // so it only acts when there is something to come out of. + "left" if detail.is_some() || loading => { + if let Ok(mut g) = state.lock() { + g.want = None; + g.detail = None; + g.stack_rows.clear(); + g.loading = false; + g.stages.clear(); + } + stack_sel = 0; + } + "esc" => { + if detail.is_some() || loading { + if let Ok(mut g) = state.lock() { + g.want = None; + g.detail = None; + g.stack_rows.clear(); + g.loading = false; + g.stages.clear(); + } + stack_sel = 0; + } else { + needle.clear(); + } + } + // Right and enter both go in - and from inside the stack, + // in again, onto the PR under the cursor. + "right" | "enter" => { + if let Some(open) = &detail { + if !stack_rows.is_empty() { + // Walk the stack from inside it: the row under + // the cursor becomes the PR on screen. + let node = &stack_rows[stack_sel.min(stack_rows.len() - 1)].1; + let url = text(open, "url"); + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() >= 5 && number(node, "number") != number(open, "number") + { + if let Ok(mut g) = state.lock() { + g.want = Some(( + parts[3].to_string(), + parts[4].to_string(), + number(node, "number"), + )); + g.detail = None; + g.stack_rows.clear(); + g.loading = true; + g.target = + format!("{}/{} #{}", parts[3], parts[4], number(node, "number")); + g.stages.clear(); + } + stack_sel = 0; + nudge(&wake); + } + } + } else if !shown.is_empty() && !loading { + let pick = &shown[selected.min(shown.len() - 1)]; + let full = text(&pick["repository"], "nameWithOwner"); + if let Some((owner, name)) = full.split_once('/') { + if let Ok(mut g) = state.lock() { + g.want = + Some((owner.into(), name.into(), number(pick, "number"))); + g.detail = None; + g.stack_rows.clear(); + g.loading = true; + g.target = format!("{} #{}", full, number(pick, "number")); + g.stages.clear(); + } + nudge(&wake); + } + } + } + "c" | "C" => { + // The URL of whatever is on screen: the open PR in the + // dashboard, the highlighted row in the list. + let url = match &detail { + Some(d) => text(d, "url"), + None => shown + .get(selected.min(shown.len().saturating_sub(1))) + .map(|pr| text(pr, "url")) + .unwrap_or_default(), + }; + if !url.is_empty() { + copied = ( + if tc::clipboard(&url) { + url + } else { + format!("no clipboard: {}", url) + }, + now(), + ); + } + } + "r" | "R" => nudge(&wake), + "f" | "F" => { + // Every PR remembers which sources found it, so + // narrowing to one is instant and costs no request. + let at = filter_names + .iter() + .position(|n| *n == source_filter) + .unwrap_or(0); + source_filter = filter_names[(at + 1) % filter_names.len()].clone(); + } + "s" | "S" => sort_at = (sort_at + 1) % SORTS.len(), + "o" | "O" => newest_first = !newest_first, + "t" | "T" => show_stats = !show_stats, + "up" => { + if detail.is_some() { + stack_sel = stack_sel.saturating_sub(1); + } else { + selected = selected.saturating_sub(1); + } + } + "down" => { + if detail.is_some() { + stack_sel += 1; + } else { + selected += 1; + } + } + _ => {} + } + } + + let (w, h) = tc::size(); + let mut rows = vec![tc::title("pr watch", w, &p.pr)]; + let mut head = vec![ + // "at least", because a source that filled its page has more + // behind it and the sources overlap, so the union cannot be + // added up - only bounded from below. docs/pr.md has promised + // the header would say so since before the port. + ( + p.dim.as_str(), + format!( + " {} of {}{}", + shown.len(), + if capped { "at least " } else { "" }, + total + ), + ), + ( + p.dim.as_str(), + if !needle.is_empty() || source_filter != "all" { + " shown".to_string() + } else { + " open".to_string() + }, + ), + ( + p.dim.as_str(), + format!( + " updated {} ago", + if fetched > 0.0 { + let s = now() - fetched; + if s < 3600.0 { + format!("{}m", ((s / 60.0) as i64).max(1)) + } else { + format!("{}h", (s / 3600.0) as i64) + } + } else { + "--".into() + } + ), + ), + ]; + if let Some(left) = rate.lock().map(|g| g.remaining).unwrap_or(None) { + head.push((p.dim.as_str(), format!(" {} api", left))); + } + if !copied.0.is_empty() && now() - copied.1 < 4.0 { + head.push((p.ok.as_str(), " copied ".into())); + head.push(( + p.dim.as_str(), + copied.0.chars().take(w.saturating_sub(46).max(10)).collect(), + )); + } + rows.push(tc::seg(&head, w - 1)); + if !err.is_empty() { + rows.push(tc::seg(&[(p.bad.as_str(), format!(" ! {}", err))], w - 1)); + } + + let hints: Vec<Vec<(&str, String)>> = if detail.is_some() || loading { + let mut stack_sel_clamped = stack_sel; + if !stack_rows.is_empty() { + stack_sel_clamped = stack_sel.min(stack_rows.len() - 1); + stack_sel = stack_sel_clamped; + } + let top = rows.len(); + rows.extend(detail_view( + detail.as_ref(), + &stack_rows, + stack_sel_clamped, + loading, + w, + h, + tick, + &stages, + &target, + top, + &p, + )); + let mut hints: Vec<Vec<(&str, String)>> = Vec::new(); + if !stack_rows.is_empty() { + hints.push(vec![ + (p.accent.as_str(), "↑↓".into()), + (p.dim.as_str(), " stack".into()), + ]); + hints.push(vec![(p.dim.as_str(), "[↵] open it".into())]); + } + hints.push(vec![(p.dim.as_str(), "[c]opy url".into())]); + hints.push(vec![ + (p.accent.as_str(), "←".into()), + (p.dim.as_str(), "/[esc] back".into()), + ]); + hints.push(vec![(p.dim.as_str(), "[r]efresh".into())]); + hints.push(vec![(p.dim.as_str(), "[q]uit".into())]); + hints + } else { + if !shown.is_empty() && selected >= shown.len() { + selected = shown.len() - 1; + } + // The stats cost eight rows; below thirty they would leave the + // list too short to be a list, so they stand down without asking. + if show_stats && h >= 30 { + // Every open PR, not `shown`: the filter is a search of the + // board, not a redefinition of it. + rows.extend(stats_view( + &sort_prs(&prs, SORTS[sort_at], newest_first), + total, + capped, + w, + &p, + )); + } + let top = rows.len(); + rows.extend(list_view( + &shown, + selected, + SORTS[sort_at], + newest_first, + &needle, + w, + h, + fetched == 0.0, + &source_filter, + top, + &p, + )); + vec![ + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " select".into())], + vec![(p.dim.as_str(), "[↵] open".into())], + vec![(p.dim.as_str(), "[/]filter".into())], + vec![(p.dim.as_str(), format!("[s]ort {}", SORTS[sort_at]))], + vec![( + p.dim.as_str(), + format!("[o]rder {}", if newest_first { "newest" } else { "oldest" }), + )], + vec![(p.dim.as_str(), format!("[f]rom {}", source_filter))], + vec![( + p.dim.as_str(), + format!("[t]stats {}", if show_stats { "on" } else { "off" }), + )], + vec![(p.dim.as_str(), "[c]opy url".into())], + vec![(p.dim.as_str(), "[r]efresh".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ] + }; + let hints = if typing { + vec![ + vec![(p.accent.as_str(), format!("/{}▌", needle))], + vec![(p.dim.as_str(), "[↵] keep".into())], + vec![(p.dim.as_str(), "[esc] clear".into())], + ] + } else { + hints + }; + let footer: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + rows.truncate(h.saturating_sub(footer.len())); + while rows.len() < h.saturating_sub(footer.len()) { + rows.push(String::new()); + } + rows.extend(footer); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(300)); + } +} + +/// Shape and age of every open PR, whatever the list is filtered to. +/// +/// Deliberately not the filtered set. Typing in the filter is a search, and +/// a search should not move the backlog it is searching: watching the age +/// median and the state bar lurch on every keystroke made them unreadable +/// and, worse, made them look like statements about the whole board when +/// they described three matching rows. +fn stats_view( + prs: &[serde_json::Value], + total: usize, + capped: bool, + w: usize, + p: &Palette, +) -> Vec<String> { + let mut rows = vec![String::new()]; + if prs.is_empty() { + return rows; + } + let n = prs.len(); + let mut review: HashMap<&str, usize> = HashMap::new(); + let mut checks: HashMap<&str, usize> = HashMap::new(); + let (mut drafts, mut conflicts, mut ready) = (0usize, 0usize, 0usize); + for pr in prs { + let decision = text(pr, "reviewDecision"); + let slot = match decision.as_str() { + "APPROVED" => "APPROVED", + "CHANGES_REQUESTED" => "CHANGES_REQUESTED", + "REVIEW_REQUIRED" => "REVIEW_REQUIRED", + _ => "", + }; + *review.entry(slot).or_insert(0) += 1; + let state = rollup(pr); + let slot = match state.as_str() { + "SUCCESS" => "SUCCESS", + "FAILURE" => "FAILURE", + "PENDING" => "PENDING", + _ => "other", + }; + *checks.entry(slot).or_insert(0) += 1; + if pr["isDraft"].as_bool().unwrap_or(false) { + drafts += 1; + } + if text(pr, "mergeable") == "CONFLICTING" { + conflicts += 1; + } + if ready_to_merge(pr) { + ready += 1; + } + } + + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── STATE ── ".into()), + (p.txt.as_str(), format!("{}", n)), + // Everything after this counts the PRs in hand. When a source + // filled its page they are a sample of the board rather than + // the board, and the line has to say which it is describing. + ( + p.dim.as_str(), + if capped { + format!(" fetched of at least {} open · ", total) + } else { + " open · ".to_string() + }, + ), + (p.dim.as_str(), format!("{} draft", drafts)), + (p.dim.as_str(), " · ".into()), + ( + if conflicts > 0 { p.bad.as_str() } else { p.dim.as_str() }, + format!("{} conflicting", conflicts), + ), + (p.dim.as_str(), " · ".into()), + ( + if ready > 0 { p.ok.as_str() } else { p.dim.as_str() }, + format!("{} ready to merge", ready), + ), + ], + w - 1, + )); + let order: Vec<(&str, &str)> = vec![ + ("APPROVED", p.ok.as_str()), + ("CHANGES_REQUESTED", p.bad.as_str()), + ("REVIEW_REQUIRED", p.warn.as_str()), + ("", p.dim.as_str()), + ]; + let parts: Vec<(f64, String)> = order + .iter() + .filter_map(|(k, c)| { + let got = review.get(k).copied().unwrap_or(0); + if got == 0 { + return None; + } + Some((got as f64 / n as f64, c.to_string())) + }) + .collect(); + let bar = tc::stacked_bar(&parts, w.saturating_sub(3).max(10)); + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, txt) in &bar { + line.push((colour.as_str(), txt.clone())); + } + rows.push(tc::seg(&line, w - 1)); + let mut key: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (k, colour) in &order { + let got = review.get(k).copied().unwrap_or(0); + if got == 0 { + continue; + } + key.push((colour, "▇ ".into())); + key.push((p.txt.as_str(), review_label(k, p).0.into())); + key.push((p.dim.as_str(), format!(" {} ", got))); + } + for (k, colour, label) in [ + ("SUCCESS", p.ok.as_str(), "checks pass"), + ("FAILURE", p.bad.as_str(), "checks FAIL"), + ("PENDING", p.warn.as_str(), "running"), + ] { + let got = checks.get(k).copied().unwrap_or(0); + if got == 0 { + continue; + } + key.push((colour, "· ".into())); + key.push((p.txt.as_str(), label.into())); + key.push((p.dim.as_str(), format!(" {} ", got))); + } + rows.push(tc::seg(&key, w - 1)); + + let mut ages: Vec<(f64, &serde_json::Value)> = prs + .iter() + .filter_map(|pr| hours_since(&text(pr, "createdAt")).map(|h| (h, pr))) + .collect(); + ages.sort_by(|a, b| a.0.total_cmp(&b.0)); + let idles: Vec<(f64, &serde_json::Value)> = prs + .iter() + .filter_map(|pr| hours_since(&text(pr, "updatedAt")).map(|h| (h, pr))) + .collect(); + + // When the open ones arrived. + let today = Utc::now().date_naive(); + let days: Vec<String> = (0..OPENED_DAYS) + .rev() + .map(|k| (today - chrono::Duration::days(k)).format("%Y-%m-%d").to_string()) + .collect(); + let mut per_day: HashMap<&String, usize> = days.iter().map(|d| (d, 0)).collect(); + let mut inside = 0usize; + for pr in prs { + let key: String = text(pr, "createdAt").chars().take(10).collect(); + if let Some(slot) = days.iter().find(|d| **d == key) { + *per_day.get_mut(slot).unwrap() += 1; + inside += 1; + } + } + let avail = w.saturating_sub(3).max(10); + let slot = (avail / days.len()).max(1); + let gap = if slot >= 3 { 1 } else { 0 }; + let barw = slot - gap; + let mut cols: Vec<(f64, String)> = Vec::new(); + for (i, d) in days.iter().enumerate() { + let value = per_day.get(d).copied().unwrap_or(0) as f64; + cols.extend(std::iter::repeat_n((value, p.pr.clone()), barw)); + if gap > 0 && i + 1 < days.len() { + cols.extend(std::iter::repeat_n((0.0, p.pr.clone()), gap)); + } + } + let peak = per_day.values().copied().max().unwrap_or(0); + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPENED / DAY ── ".into()), + (p.dim.as_str(), format!("last {}d · ", OPENED_DAYS)), + (p.txt.as_str(), format!("{}", inside)), + (p.dim.as_str(), format!(" of {} still open · ", n)), + (p.dim.as_str(), format!("peak {}/day", peak)), + ], + w - 1, + )); + if peak > 0 { + for line in tc::vbars(&cols, 3, 0.0) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + rows.push(tc::seg( + &[(tc::RST, " ".into()), (p.grid.as_str(), "─".repeat(cols.len()))], + w - 1, + )); + let left = format!("{}d ago", OPENED_DAYS); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", left)), + ( + p.dim.as_str(), + " ".repeat(cols.len().saturating_sub(left.len() + 5).max(1)), + ), + (p.dim.as_str(), "today".into()), + ], + w - 1, + )); + } else { + rows.push(tc::seg( + &[( + p.dim.as_str(), + format!( + " none of the open PRs were opened in the last {}d", + OPENED_DAYS + ), + )], + w - 1, + )); + } + + let at = |pairs: &[(f64, &serde_json::Value)], frac: f64| -> Option<f64> { + if pairs.is_empty() { + return None; + } + let mut vals: Vec<f64> = pairs.iter().map(|x| x.0).collect(); + vals.sort_by(f64::total_cmp); + Some(vals[((vals.len() as f64 * frac) as usize).min(vals.len() - 1)]) + }; + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── AGE ── ".into()), + (p.dim.as_str(), "median ".into()), + (p.txt.as_str(), span(at(&ages, 0.5))), + (p.dim.as_str(), " p95 ".into()), + (p.txt.as_str(), span(at(&ages, 0.95))), + (p.dim.as_str(), " max ".into()), + (p.warn.as_str(), span(at(&ages, 1.0))), + (p.dim.as_str(), " idle median ".into()), + (p.txt.as_str(), span(at(&idles, 0.5))), + ], + w - 1, + )); + if !ages.is_empty() { + // One bar per open PR, youngest left to oldest right - the x axis is + // rank, not time. It fills the pane and carries a baseline and end + // labels, because a sparkline that stops in the middle of the screen + // gives no way to tell where the chart ends and the blank begins. + let room = w.saturating_sub(3).max(10); + let drawn: &[(f64, &serde_json::Value)] = if ages.len() > room { + &ages[ages.len() - room..] + } else { + &ages + }; + // Spread the remainder across the leftmost bars so the chart reaches + // the right edge exactly: stopping short of it left no way to tell a + // finished chart from a truncated one. + let (slot, extra) = if drawn.len() >= room { + (1, 0) + } else { + (room / drawn.len(), room % drawn.len()) + }; + let hi = drawn.iter().map(|x| x.0).fold(0.0f64, f64::max).max(1.0); + let mut bars = String::new(); + for (i, (hours, _)) in drawn.iter().enumerate() { + let wide_bar = slot + usize::from(i < extra); + let level = ((hours / hi) * 7.99) as usize; + for _ in 0..wide_bar { + bars.push(SPARK[level.min(7)]); + } + } + let count = bars.chars().count(); + rows.push(tc::seg( + &[(tc::RST, " ".into()), (&tc::heat(0.4), bars)], + w - 1, + )); + rows.push(tc::seg( + &[(tc::RST, " ".into()), (p.grid.as_str(), "─".repeat(count))], + w - 1, + )); + let left = format!("youngest {}", span(Some(drawn[0].0))); + let right = format!("oldest {}", span(Some(drawn[drawn.len() - 1].0))); + let note = if drawn.len() < ages.len() { + format!("{} of {} PRs", drawn.len(), ages.len()) + } else { + format!("{} PRs", drawn.len()) + }; + let mid = count + .saturating_sub(left.len() + right.len() + note.len() + 2) + .max(1); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", left)), + (p.dim.as_str(), " ".repeat(mid / 2)), + (p.grid.as_str(), note), + (p.dim.as_str(), " ".repeat(mid - mid / 2 + 2)), + (p.dim.as_str(), right), + ], + w - 1, + )); + } + if let Some(fattest) = prs.iter().max_by_key(|p| number(p, "additions") + number(p, "deletions")) + { + let worst = |pairs: &[(f64, &serde_json::Value)]| -> String { + match pairs.iter().max_by(|a, b| a.0.total_cmp(&b.0)) { + Some((hours, pr)) => { + format!("#{} {}", number(pr, "number"), span(Some(*hours))) + } + None => "--".into(), + } + }; + rows.push(tc::seg( + &[ + (p.dim.as_str(), " oldest ".into()), + (p.warn.as_str(), tc::pad(&worst(&ages), 12)), + (p.dim.as_str(), " untouched longest ".into()), + (p.warn.as_str(), tc::pad(&worst(&idles), 12)), + (p.dim.as_str(), " biggest ".into()), + ( + p.txt.as_str(), + format!( + "#{} +{}/-{}", + number(fattest, "number"), + number(fattest, "additions"), + number(fattest, "deletions") + ), + ), + ], + w - 1, + )); + } + rows +} + +#[allow(clippy::too_many_arguments)] +fn list_view( + prs: &[serde_json::Value], + selected: usize, + sort_field: &str, + newest_first: bool, + needle: &str, + w: usize, + h: usize, + waiting: bool, + source_filter: &str, + top: usize, + p: &Palette, +) -> Vec<String> { + let mut rows = vec![String::new()]; + let arrow = if newest_first { "↓" } else { "↑" }; + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPEN PRs ── ".into()), + (p.dim.as_str(), format!("by {} {}", sort_field, arrow)), + ( + p.dim.as_str(), + if source_filter != "all" { + format!(" from {}", source_filter) + } else { + String::new() + }, + ), + ( + p.accent.as_str(), + if needle.is_empty() { + String::new() + } else { + format!(" /{}", needle) + }, + ), + ], + w - 1, + )); + if prs.is_empty() { + // "collecting" is only true before the first fetch: an empty filter + // or an empty source is a result, not a wait. + let why = if !needle.is_empty() { + format!(" nothing matches /{}", needle) + } else if source_filter != "all" { + format!(" no open PRs from {}", source_filter) + } else if waiting { + " collecting…".to_string() + } else { + " no open PRs".to_string() + }; + rows.push(tc::seg(&[(p.dim.as_str(), why)], w - 1)); + return rows; + } + + // Columns are budgeted rather than guessed: the fixed ones are summed + // and the title takes exactly what is left, so nothing runs off the + // right edge or into its neighbour. + let wide = w >= 96; + let repo_w = if wide { 18 } else { 0 }; + let size_w = if wide { 12 } else { 0 }; + let fixed = 8 + repo_w + 13 + 8 + 6 + size_w; + let title_w = (w - 1).saturating_sub(fixed).max(16); + let mut head = format!(" {:<7}", "PR"); + if repo_w > 0 { + head += &format!("{:<width$}", "REPO", width = repo_w); + } + // The time column follows the sort, so the number you ordered by is the + // number you can see. Labelling both "AGE" had it reporting idle time + // while the stats above reported true age, and the two disagreed. + let when_label = if sort_field == "created" { "AGE" } else { "IDLE" }; + head += &format!( + "{:<width$}{:>13}{:>8}{:>6}", + "TITLE", + "REVIEW", + "CHECKS", + when_label, + width = title_w + ); + if size_w > 0 { + head += &format!("{:>width$}", "SIZE", width = size_w); + } + rows.push(tc::seg(&[(p.dim.as_str(), tc::pad(&head, w - 1))], w - 1)); + + // `top` is what was drawn above this view. Without it the window is + // sized as though the list began at the top of the screen, so it renders + // far more rows than are visible, the caller truncates the overflow, and + // the selection scrolls off the bottom while `first` is still 0. + let room = h.saturating_sub(top + rows.len() + 3).max(1); + let first = if prs.len() > room { + selected.saturating_sub(room / 2).min(prs.len() - room) + } else { + 0 + }; + for (i, pr) in prs.iter().enumerate().skip(first).take(room) { + let here = i == selected; + let tint = if here { tc::bg(38, 56, 76) } else { String::new() }; + let c = |colour: &str| { + // Any colour that would not clear AA on this tint is swapped + // for its lighter twin. `dim` was measured first; a review + // found the others after the first fix shipped saying it was + // done, so they are here by measurement rather than by guess. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else if colour == p.bad { + p.bad_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let (rlabel, rcol) = review_label(&text(pr, "reviewDecision"), p); + let (clabel, ccol) = check_label(&rollup(pr), p); + let stacked = !pr["stackEntry"].is_null(); + let mut line = vec![( + c(if here { &p.accent } else { &p.pr }), + format!( + "{}{}", + if here { "▸" } else { " " }, + tc::pad(&format!("#{}", number(pr, "number")), 7) + ), + )]; + if repo_w > 0 { + // Clipped one short of the column so it never touches the title. + let full = text(&pr["repository"], "nameWithOwner"); + let repo = full.rsplit('/').next().unwrap_or("").to_string(); + line.push(( + c(&p.dim), + tc::pad( + &repo.chars().take(repo_w - 1).collect::<String>(), + repo_w, + ), + )); + } + let mut name = format!( + "{}{}", + if stacked { "⣿ " } else { "" }, + text(pr, "title") + ); + if pr["isDraft"].as_bool().unwrap_or(false) { + name = format!("draft · {}", name); + } + line.push(( + c(&p.txt), + tc::pad( + &name.chars().take(title_w - 1).collect::<String>(), + title_w, + ), + )); + line.push((c(rcol), format!("{:>13}", rlabel))); + line.push((c(ccol), format!("{:>8}", clabel))); + line.push(( + c(&p.dim), + format!( + "{:>6}", + ago(&text( + pr, + if sort_field == "created" { "createdAt" } else { "updatedAt" } + )) + ), + )); + if size_w > 0 { + line.push(( + c(&p.dim), + format!( + "{:>width$}", + format!("+{}/-{}", number(pr, "additions"), number(pr, "deletions")), + width = size_w + ), + )); + } + if here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + rows +} + +#[allow(clippy::too_many_arguments)] +fn detail_view( + pr: Option<&serde_json::Value>, + stack_rows: &[StackRow], + stack_sel: usize, + loading: bool, + w: usize, + h: usize, + tick: usize, + stages: &[Stage], + target: &str, + top: usize, + p: &Palette, +) -> Vec<String> { + let mut rows = vec![String::new()]; + let Some(pr) = pr.filter(|_| !loading) else { + // A shimmer says "wait" and nothing else. The open really does run + // in stages, so show them: a spinner on the one in flight, a tick + // and a duration on the ones behind it. Honest, and it reads like a + // machine doing something rather than a placeholder. + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OPENING ── ".into()), + (p.accent.as_str(), target.to_string()), + ], + w - 1, + )); + rows.push(String::new()); + let spin = SPINNER[tick % SPINNER.len()]; + for st in stages { + rows.push(tc::seg( + &[ + ( + if st.done { p.ok.as_str() } else { p.accent.as_str() }, + format!(" {} ", if st.done { '✓' } else { spin }), + ), + ( + if st.done { p.txt.as_str() } else { p.dim.as_str() }, + tc::pad(&st.label, w.saturating_sub(22).max(20)), + ), + ( + p.dim.as_str(), + format!( + "{:>6}", + if st.took > 0.0 { + format!("{:.1}s", st.took) + } else { + String::new() + } + ), + ), + ], + w - 1, + )); + } + if stages.is_empty() { + rows.push(tc::seg( + &[ + (p.accent.as_str(), format!(" {} ", spin)), + (p.dim.as_str(), "connecting".into()), + ], + w - 1, + )); + } + rows.push(String::new()); + // One sweeping line rather than four fat bars of shimmer. + let shimmer = tc::skeleton(w.saturating_sub(6).max(10), tick * 2, 7); + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, txt) in &shimmer { + line.push((colour.as_str(), txt.clone())); + } + rows.push(tc::seg(&line, w - 1)); + return rows; + }; + + let draft = if pr["isDraft"].as_bool().unwrap_or(false) { + " · draft" + } else { + "" + }; + rows.push(tc::seg( + &[ + (p.pr.as_str(), format!(" #{} ", number(pr, "number"))), + ( + p.txt.as_str(), + text(pr, "title") + .chars() + .take(w.saturating_sub(24).max(10)) + .collect::<String>(), + ), + (p.dim.as_str(), draft.into()), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " ".into()), + ( + p.dim.as_str(), + match text(&pr["author"], "login") { + s if s.is_empty() => "?".into(), + s => s, + }, + ), + (p.dim.as_str(), " ".into()), + (p.accent.as_str(), text(pr, "headRefName")), + (p.dim.as_str(), " → ".into()), + (p.accent.as_str(), text(pr, "baseRefName")), + ], + w - 1, + )); + + let (rlabel, rcol) = review_label(&text(pr, "reviewDecision"), p); + let (mlabel, mcol) = merge_label(&text(pr, "mergeStateStatus"), p); + let unresolved = pr["reviewThreads"]["nodes"] + .as_array() + .into_iter() + .flatten() + .filter(|t| !t["isResolved"].as_bool().unwrap_or(false)) + .count(); + rows.push(String::new()); + let cells: Vec<(String, String, &str)> = vec![ + ("review".into(), rlabel.into(), rcol), + ("merge".into(), mlabel.into(), mcol), + ( + "unresolved threads".into(), + unresolved.to_string(), + if unresolved > 0 { p.bad.as_str() } else { p.ok.as_str() }, + ), + ( + "size".into(), + format!( + "+{}/-{} in {} files", + number(pr, "additions"), + number(pr, "deletions"), + number(pr, "changedFiles") + ), + p.txt.as_str(), + ), + ( + "commits".into(), + number(&pr["commitCount"], "totalCount").to_string(), + p.txt.as_str(), + ), + ( + "opened / updated".into(), + format!( + "{} ago / {} ago", + ago(&text(pr, "createdAt")), + ago(&text(pr, "updatedAt")) + ), + p.txt.as_str(), + ), + ]; + let label_w = cells.iter().map(|c| c.0.len()).max().unwrap_or(8); + let ncols = if (w - 2) / 2 >= label_w + 3 + 18 { 2 } else { 1 }; + let cw = (w - 2) / ncols; + let val_w = cw.saturating_sub(label_w + 3).max(6); + for chunk in cells.chunks(ncols) { + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, value, colour) in chunk { + line.push((p.dim.as_str(), format!(" {} ", tc::pad(label, label_w)))); + line.push((colour, tc::pad(value, val_w))); + } + rows.push(tc::seg(&line, w - 1)); + } + + // Who has looked at it. The last state per person wins: someone who + // requested changes and later approved has approved, and showing both + // would misreport the gate. + let mut latest: HashMap<String, String> = HashMap::new(); + for r in pr["reviews"]["nodes"].as_array().into_iter().flatten() { + let who = text(&r["author"], "login"); + if !who.is_empty() { + latest.insert(who, text(r, "state")); + } + } + let mut pending: Vec<String> = Vec::new(); + for n in pr["reviewRequests"]["nodes"].as_array().into_iter().flatten() { + let who = &n["requestedReviewer"]; + let name = match text(who, "login") { + s if !s.is_empty() => s, + _ => text(who, "name"), + }; + if !name.is_empty() && !latest.contains_key(&name) { + pending.push(name); + } + } + let pick = |state: &str| -> Vec<String> { + let mut out: Vec<String> = latest + .iter() + .filter(|(_, v)| *v == state) + .map(|(k, _)| k.clone()) + .collect(); + out.sort(); + out + }; + let groups: Vec<(&str, &str, Vec<String>)> = vec![ + ("approved", p.ok.as_str(), pick("APPROVED")), + ("changes requested", p.bad.as_str(), pick("CHANGES_REQUESTED")), + ("commented", p.dim.as_str(), pick("COMMENTED")), + ("awaiting", p.warn.as_str(), { + pending.sort(); + pending.clone() + }), + ]; + rows.push(String::new()); + let live: Vec<&(&str, &str, Vec<String>)> = groups.iter().filter(|g| !g.2.is_empty()).collect(); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── REVIEWERS ── ".into()), + ( + p.dim.as_str(), + if live.is_empty() { + "nobody has been asked".to_string() + } else { + live.iter() + .map(|g| format!("{} {}", g.2.len(), g.0)) + .collect::<Vec<_>>() + .join(" · ") + }, + ), + ], + w - 1, + )); + for (label, colour, who) in &live { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", tc::pad(label, 18))), + ( + colour, + who.join(", ") + .chars() + .take(w.saturating_sub(24).max(10)) + .collect::<String>(), + ), + ], + w - 1, + )); + } + + let roll = pr["commits"]["nodes"] + .as_array() + .and_then(|a| a.first()) + .map(|n| n["commit"]["statusCheckRollup"].clone()) + .unwrap_or(serde_json::Value::Null); + rows.push(String::new()); + if roll.is_null() { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── CHECKS ── ".into()), + (p.dim.as_str(), "none on the last commit".into()), + ], + w - 1, + )); + } else { + let (state, scol) = check_label(&text(&roll, "state"), p); + let ctx: Vec<&serde_json::Value> = roll["contexts"]["nodes"] + .as_array() + .into_iter() + .flatten() + .filter(|c| !c.is_null()) + .collect(); + // The query asks for one page of contexts, and a repository with + // more of them than that fits sent back a page rather than all of + // them - so `ctx.len()` is how many arrived, not how many ran. The + // rollup reports the true count for a point of field complexity, and + // a run that is missing here is exactly the one worth knowing about: + // a failure outside the page cannot be named, only counted. + let counted = roll["contexts"]["totalCount"].as_i64().unwrap_or(0).max(0) as usize; + // Never below what is in hand: a missing field must not turn eleven + // checks into "11 of 0". + let counted = counted.max(ctx.len()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── CHECKS ── ".into()), + (scol, state.into()), + ( + p.dim.as_str(), + if ctx.len() < counted { + format!(" {} of {} fetched", ctx.len(), counted) + } else { + format!(" {} total", counted) + }, + ), + ], + w - 1, + )); + let verdict_of = |c: &serde_json::Value| -> String { + for key in ["conclusion", "state", "status"] { + let v = text(c, key); + if !v.is_empty() { + return v; + } + } + String::new() + }; + let is_bad = |c: &serde_json::Value| -> bool { + let v = verdict_of(c); + !matches!(v.as_str(), "SUCCESS" | "NEUTRAL" | "SKIPPED" | "") + }; + // Failures first: a green wall of passing checks is not why anyone + // opens this view. + let ordered: Vec<&&serde_json::Value> = ctx + .iter() + .filter(|c| is_bad(c)) + .chain(ctx.iter().filter(|c| !is_bad(c))) + .collect(); + for c in ordered.into_iter().take(8) { + let name = match text(c, "name") { + s if !s.is_empty() => s, + _ => match text(c, "context") { + s if !s.is_empty() => s, + _ => "?".into(), + }, + }; + let verdict = verdict_of(c); + let (lab, col) = check_label(&verdict, p); + let lab = if lab == "—" && !verdict.is_empty() { + verdict.to_lowercase() + } else { + lab.to_string() + }; + let took = match ( + parse(&text(c, "startedAt")), + parse(&text(c, "completedAt")), + ) { + (Some(a), Some(b)) => format!("{}s", (b - a).num_seconds()), + _ => String::new(), + }; + rows.push(tc::seg( + &[ + (p.dim.as_str(), " ".into()), + (p.txt.as_str(), tc::pad(&name, w.saturating_sub(30).max(12))), + (col, format!("{:>10}", lab)), + (p.dim.as_str(), format!("{:>8}", took)), + ], + w - 1, + )); + } + } + + if !stack_rows.is_empty() { + let native = !pr["stack"].is_null(); + rows.push(String::new()); + // The stack scrolls: eleven-deep stacks exist, and a pane that has + // already spent its height on checks cannot show them all. + let room = h.saturating_sub(top + rows.len() + 4).max(3); + let first = if stack_rows.len() > room { + stack_sel.saturating_sub(room / 2).min(stack_rows.len() - room) + } else { + 0 + }; + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── STACK ── ".into()), + ( + p.dim.as_str(), + format!( + "{} pull requests · {}", + stack_rows.len(), + if native { "from GitHub" } else { "inferred from branches" } + ), + ), + ( + p.accent.as_str(), + if stack_rows.len() > room { + format!( + " ↑↓ {}-{} of {}", + first + 1, + (first + room).min(stack_rows.len()), + stack_rows.len() + ) + } else { + String::new() + }, + ), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " merge bottom-up: ".into()), + (p.txt.as_str(), "the one nearest the base branch first".into()), + (p.dim.as_str(), " ▸ cursor · ● on screen".into()), + ], + w - 1, + )); + let base = if native { + text(&pr["stack"], "baseRefName") + } else { + text(pr, "baseRefName") + }; + rows.push(tc::seg( + &[ + (p.dim.as_str(), " ".into()), + ( + p.accent.as_str(), + if base.is_empty() { "trunk".into() } else { base }, + ), + ], + w - 1, + )); + for (idx, (twig, node, is_here, position)) in + stack_rows.iter().enumerate().skip(first).take(room) + { + let (lab, col) = review_label(&text(node, "reviewDecision"), p); + let (mlab, mcol) = match text(node, "mergeable").as_str() { + "CONFLICTING" => ("CONFLICT", p.bad.as_str()), + "MERGEABLE" => ("ok", p.ok.as_str()), + _ => ("…", p.dim.as_str()), + }; + let on_cursor = idx == stack_sel; + let tint = if on_cursor { tc::bg(38, 56, 76) } else { String::new() }; + let c = |colour: &str| { + // Any colour that would not clear AA on this tint is swapped + // for its lighter twin. `dim` was measured first; a review + // found the others after the first fix shipped saying it was + // done, so they are here by measurement rather than by guess. + let colour = if tint.is_empty() { + colour + } else if colour == p.dim { + p.dim_lit.as_str() + } else if colour == p.bad { + p.bad_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let mut name = text(node, "title"); + if let Some(pos) = position { + name = format!("{}. {}", pos, name); + } + // Two gutter marks, because they answer different questions: ▸ + // is where the cursor is, ● is the PR actually on screen. One + // symbol plus a colour could not say both. + let gutter = format!( + "{}{}", + if on_cursor { "▸" } else { " " }, + if *is_here { "●" } else { " " } + ); + let name_w = w + .saturating_sub(34 + twig.chars().count()) + .max(10); + let mut line = vec![ + (c(if on_cursor { &p.accent } else { &p.dim }), gutter), + (c(&p.dim), twig.clone()), + ( + c(if on_cursor { &p.accent } else { &p.pr }), + format!("#{:<5} ", number(node, "number")), + ), + ( + c(if *is_here || on_cursor { &p.txt } else { &p.dim }), + tc::pad(&name.chars().take(name_w).collect::<String>(), name_w), + ), + (c(col), format!("{:>13}", lab)), + (c(mcol), format!("{:>10}", mlab)), + ]; + if on_cursor { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ready_means_nothing_is_left_to_do() { + let pr = |json: &str| -> serde_json::Value { serde_json::from_str(json).unwrap() }; + let green = pr(r#"{"reviewDecision": "APPROVED", "mergeable": "MERGEABLE", + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}}"#); + assert!(ready_to_merge(&green)); + // A repo with no CI at all still counts: no checks is not a failing + // check, and holding those back would empty the one actionable + // number on the board. + let no_ci = pr(r#"{"reviewDecision": "APPROVED", "mergeable": "MERGEABLE"}"#); + assert!(ready_to_merge(&no_ci)); + // Every one of the four gates on its own is enough to hold it. + assert!(!ready_to_merge(&pr( + r#"{"reviewDecision": "REVIEW_REQUIRED", "mergeable": "MERGEABLE"}"# + ))); + assert!(!ready_to_merge(&pr( + r#"{"reviewDecision": "APPROVED", "mergeable": "CONFLICTING"}"# + ))); + assert!(!ready_to_merge(&pr( + r#"{"reviewDecision": "APPROVED", "isDraft": true}"# + ))); + assert!(!ready_to_merge(&pr( + r#"{"reviewDecision": "APPROVED", + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "FAILURE"}}}]}}"# + ))); + } + + #[test] + fn a_stack_is_inferred_from_where_the_branches_sit() { + // main <- a <- b, and c also on a: a tree rather than a line. + let prs: Vec<serde_json::Value> = serde_json::from_str( + r#"[{"number": 1, "headRefName": "a", "baseRefName": "main"}, + {"number": 2, "headRefName": "b", "baseRefName": "a"}, + {"number": 3, "headRefName": "c", "baseRefName": "a"}]"#, + ) + .unwrap(); + let (root, parent, kids) = stack_of(2, &prs); + assert_eq!(root, Some(1)); + assert_eq!(parent.get(&2), Some(&1)); + let mut branched = kids.get(&1).cloned().unwrap_or_default(); + branched.sort_unstable(); + assert_eq!(branched, vec![2, 3]); + // A PR that neither sits on another nor carries one has no stack, + // rather than a stack of itself. + let lone: Vec<serde_json::Value> = serde_json::from_str( + r#"[{"number": 9, "headRefName": "x", "baseRefName": "main"}]"#, + ) + .unwrap(); + assert_eq!(stack_of(9, &lone).0, None); + } + + #[test] + fn a_cycle_of_branches_does_not_hang_the_walk() { + // Two PRs each based on the other's branch. It should not happen, + // and the walk must still finish. + let prs: Vec<serde_json::Value> = serde_json::from_str( + r#"[{"number": 1, "headRefName": "a", "baseRefName": "b"}, + {"number": 2, "headRefName": "b", "baseRefName": "a"}]"#, + ) + .unwrap(); + let (root, _, _) = stack_of(1, &prs); + assert!(root.is_some()); + } + + #[test] + fn a_page_that_filled_up_turns_the_total_into_a_floor() { + // Nothing filled its page: the pool is the union, exactly. + assert_eq!(union_total(&[(30, 30), (12, 12)], 35), (35, false)); + // `orgs` filled its page of 50 with 212 behind it. The sources + // overlap, so 212 + 12 would be nonsense - but the union contains + // the whole of `orgs`, so it holds at least 212. + assert_eq!(union_total(&[(212, 50), (12, 12)], 58), (212, true)); + // A capped source can still be smaller than what is already pooled, + // and then the pool is the better floor of the two. + assert_eq!(union_total(&[(51, 50), (3, 3)], 53), (53, true)); + // One source, capped, with nothing else to add to it. + assert_eq!(union_total(&[(60, 50)], 50), (60, true)); + // No sources is no floor, and above all not a claim of zero open + // PRs dressed up as one. + assert_eq!(union_total(&[], 0), (0, false)); + } + + #[test] + fn mine_expands_to_every_org_plus_the_account() { + let sources = vec![ + ("orgs".to_string(), "is:open is:pr @mine".to_string()), + ("authored".to_string(), "is:open is:pr author:@me".to_string()), + ]; + let out = searches(&sources, "wiiiimm", &["acme".into(), "beta".into()], &[]); + assert_eq!(out[0].1, "is:open is:pr org:acme org:beta user:wiiiimm"); + // A source that does not mention @mine is left alone. + assert_eq!(out[1].1, "is:open is:pr author:@me"); + // Extra arguments are appended to every source. + let with = searches(&sources, "w", &[], &["repo:x/y".into()]); + assert!(with[1].1.ends_with("repo:x/y")); + } + + #[test] + fn the_filter_looks_at_everything_on_the_row() { + let pr: serde_json::Value = serde_json::from_str( + r#"{"number": 42, "title": "Fix the thing", + "author": {"login": "wiiiimm"}, + "repository": {"nameWithOwner": "acme/widgets"}, + "headRefName": "fix/thing", "baseRefName": "main"}"#, + ) + .unwrap(); + for needle in ["42", "thing", "WIIIIMM", "widgets", "fix/", "main"] { + assert!(matches(&pr, needle), "{} did not match", needle); + } + assert!(!matches(&pr, "nonsense")); + // No filter matches everything, rather than nothing. + assert!(matches(&pr, "")); + } + + #[test] + fn a_span_rolls_over_before_it_stops_reading() { + assert_eq!(span(None), "--"); + assert_eq!(span(Some(5.0)), "5h"); + assert_eq!(span(Some(72.0)), "3d"); + assert_eq!(span(Some(24.0 * 400.0)), "1.1y"); + } +} diff --git a/widgets/src/bin/pr_help.txt b/widgets/src/bin/pr_help.txt new file mode 100644 index 0000000..a14b680 --- /dev/null +++ b/widgets/src/bin/pr_help.txt @@ -0,0 +1,17 @@ +Watch the pull requests you have to follow up on. + +A list of open PRs, and a dashboard for whichever one is selected: checks, +reviews, mergeability, and - when the PR belongs to a stack - the stack it +sits in and the order that stack has to merge in. + + pr [-n SECONDS] [search terms ...] + +Extra arguments are appended to the search, so `pr.py org:acme` narrows to one +organisation and `pr.py author:@me` to your own. With none given it uses +`pr.query` from config, which defaults to everything you are involved in. + +Credentials: reuses `github.token` from config.json, or $GITHUB_TOKEN. A +classic token with `repo` and `read:org`, the same one github.py uses. + +Keys: up/down select, enter opens a PR, esc goes back, / filters, s cycles the +sort, o reverses it, r refreshes, q quits. diff --git a/widgets/src/bin/start.rs b/widgets/src/bin/start.rs new file mode 100644 index 0000000..5072ab3 --- /dev/null +++ b/widgets/src/bin/start.rs @@ -0,0 +1,667 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Every widget here, what it does, and whether it will work on this machine. +//! +//! A port of start.py, which reads the directory it sits in and parses each +//! script's docstring. A compiled binary has no directory to read, so the +//! same three things - the summary, the paragraph under it, and the picture +//! from the doc page - are compiled in from the same files the Python +//! parses at run time. Nothing is described twice either way. + +use std::time::Duration; + +use toys_core as tc; + +/// Each widget's own words, taken from the files that already hold them: +/// the help text every binary answers `--help` with, and the doc page that +/// opens with a picture of it. +struct Widget { + stem: &'static str, + help: &'static str, + doc: &'static str, +} + +const WIDGETS: &[Widget] = &[ + Widget { + stem: "clocks", + help: include_str!("clocks_help.txt"), + doc: include_str!("../../../docs/clocks.md"), + }, + Widget { + stem: "deployments", + help: include_str!("deployments_help.txt"), + doc: include_str!("../../../docs/deployments.md"), + }, + Widget { + stem: "github", + help: include_str!("github_help.txt"), + doc: include_str!("../../../docs/github.md"), + }, + Widget { + stem: "herdr-panes", + help: include_str!("herdr-panes_help.txt"), + doc: include_str!("../../../docs/herdr-panes.md"), + }, + Widget { + stem: "latency", + help: include_str!("latency_help.txt"), + doc: include_str!("../../../docs/latency.md"), + }, + Widget { + stem: "linear", + help: include_str!("linear_help.txt"), + doc: include_str!("../../../docs/linear.md"), + }, + Widget { + stem: "link", + help: include_str!("link_help.txt"), + doc: include_str!("../../../docs/link.md"), + }, + Widget { + stem: "matrix", + help: include_str!("matrix_help.txt"), + // The one widget with no doc page, and the only one that computes + // nothing: there is no picture of it that a still frame would carry. + doc: "", + }, + Widget { + stem: "netwatch", + help: include_str!("netwatch_help.txt"), + doc: include_str!("../../../docs/netwatch.md"), + }, + Widget { + stem: "ports", + help: include_str!("ports_help.txt"), + doc: include_str!("../../../docs/ports.md"), + }, + Widget { + stem: "pr", + help: include_str!("pr_help.txt"), + doc: include_str!("../../../docs/pr.md"), + }, + Widget { + stem: "tailnet", + help: include_str!("tailnet_help.txt"), + doc: include_str!("../../../docs/tailnet.md"), + }, + Widget { + stem: "usage", + help: include_str!("usage_help.txt"), + doc: include_str!("../../../docs/usage.md"), + }, +]; + +impl Widget { + /// The row: this widget's own first line. + fn summary(&self) -> &'static str { + self.help.lines().next().unwrap_or("") + } + + /// The aside: the paragraph under the summary, which is where each + /// widget explains why it exists. + /// + /// Only that paragraph. What follows is the usage synopsis and the key + /// list, which are for somebody reading --help rather than somebody + /// deciding whether this is the thing they want. + fn about(&self) -> String { + let mut para: Vec<&str> = Vec::new(); + for line in self.help.lines().skip(2) { + if line.starts_with(" ") { + break; // an indented usage block + } + if line.trim().is_empty() { + if !para.is_empty() { + break; + } + continue; + } + para.push(line.trim()); + } + para.join(" ").chars().take(400).collect() + } + + /// The picture from this widget's doc page, if it has one. + /// + /// Every doc opens with a rendering of the widget it describes, kept by + /// whoever wrote it and read by whoever is deciding whether to run the + /// thing. Using that means no second copy of anything - and, more to + /// the point, no widget has to be started to be looked at. + fn sample(&self) -> Vec<&'static str> { + let mut block = Vec::new(); + let mut inside = false; + for line in self.doc.lines() { + if line.starts_with("```") { + if inside { + break; + } + inside = true; + continue; + } + if inside { + block.push(line); + } + } + // Only a block that is actually a picture of the widget: the docs + // also hold shell snippets and JSON, and a config listing is not a + // preview. + match block.first() { + Some(first) if first.starts_with("╺━") => block, + _ => Vec::new(), + } + } +} + +/// Break a paragraph at spaces, for the note under the list. +fn wrap(text: &str, width: usize) -> Vec<String> { + let mut lines = Vec::new(); + let mut rest: Vec<char> = text.trim().chars().collect(); + while !rest.is_empty() && lines.len() < 3 { + if rest.len() <= width { + lines.push(rest.iter().collect()); + break; + } + let cut = rest[..(width + 1).min(rest.len())] + .iter() + .rposition(|c| *c == ' ') + .filter(|c| *c > width / 2) + .unwrap_or(width); + lines.push(rest[..cut].iter().collect()); + rest = rest[cut..].iter().skip_while(|c| **c == ' ').copied().collect(); + } + lines +} + +/// Where the widgets live: beside this binary, whatever it was called from. +fn beside(stem: &str) -> Option<std::path::PathBuf> { + let here = std::env::current_exe().ok()?; + Some(here.parent()?.join(stem)) +} + +struct Palette { + dim: String, + grid: String, + txt: String, + lbl: String, + accent: String, +} + +fn palette() -> Palette { + Palette { + dim: tc::rgb(127, 147, 172), + grid: tc::rgb(60, 78, 98), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + accent: tc::rgb(150, 210, 255), + } +} + +/// The rows to draw, and where the window sits. +/// +/// Returns the first index shown, so the caller can say what it is +/// showing rather than presenting a slice as the whole list. +fn window_for(count: usize, selected: usize, room: usize) -> (usize, usize) { + if count <= room || room == 0 { + return (0, count); + } + // Keep the cursor inside the window, scrolling only as far as it must: + // a list that jumps to centre the selection loses the reader's place. + let first = if selected < room { + 0 + } else { + (selected + 1).saturating_sub(room) + }; + (first, room) +} + +fn rows_for(w: usize, selected: usize, first: usize, room: usize, p: &Palette) -> Vec<String> { + let name_w = (w.saturating_sub(58)).clamp(12, 18); + // Every column keeps a space of its own, so a summary that fills its + // width stops short of whatever is beside it rather than running in. + let text_w = ((w - 1).saturating_sub(name_w + 6)).max(8); + WIDGETS + .iter() + .enumerate() + .skip(first) + .take(room) + .map(|(i, item)| { + let here = i == selected; + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let c = |colour: &str| format!("{}{}", tint, colour); + let mut line = vec![ + ( + c(if here { &p.accent } else { &p.dim }), + if here { " ▸ ".to_string() } else { " ".to_string() }, + ), + ( + c(if here { &p.txt } else { &p.lbl }), + tc::pad( + &item.stem.chars().take(name_w - 1).collect::<String>(), + name_w, + ), + ), + ( + c(&p.dim), + tc::pad( + &item.summary().chars().take(text_w - 1).collect::<String>(), + text_w, + ), + ), + ]; + if here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + tc::seg(&refs, w - 1) + }) + .collect() +} + +/// Hand the terminal over, and take it back when the widget exits. +fn run_widget(keyboard: &mut tc::Keyboard, stem: &str) { + keyboard.restore(); + tc::restore_screen(); + match beside(stem) { + Some(path) => { + match std::process::Command::new(&path).status() { + Ok(_) => {} + Err(e) => { + tc::out(&format!("{}: {}\r\n", path.display(), e)); + tc::flush(); + std::thread::sleep(Duration::from_secs(2)); + } + } + } + None => { + tc::out("cannot find where this binary lives\r\n"); + tc::flush(); + std::thread::sleep(Duration::from_secs(2)); + } + } + // The widget left the terminal however it left it, so take it back + // rather than assuming: cbreak again, cursor away again, screen clear. + keyboard.reclaim(); + tc::out(&format!("{}{}{}", tc::HIDE, tc::CLEAR, tc::HOME)); + tc::flush(); +} + +/// The status a supervisor should see for a launched widget. +/// +/// `ExitStatus::code()` is `None` when the child died from a signal, and +/// treating that as 0 made a crash look like a successful run. Unix +/// convention is 128 plus the signal; anywhere else, a plain failure. +fn child_exit(status: std::process::ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + return 128 + sig; + } + } + 1 +} + +fn main() { + // A widget name is resolved before --help is looked at, so that + // `start netwatch --help` is netwatch's help, not this one's. Every + // argument after the name belongs to the widget, including that one. + let args: Vec<String> = std::env::args().skip(1).collect(); + if let Some(first) = args.first() { + if !first.starts_with('-') { + // `.py` is still accepted, and only for that: every widget here + // answered to that name for years and the muscle memory outlives + // the files. It resolves to the binary of the same stem. + let wanted = first.strip_suffix(".py").unwrap_or(first); + let Some(found) = WIDGETS.iter().find(|w| w.stem == wanted) else { + eprintln!( + "no widget called {:?} - try: {}", + first, + WIDGETS + .iter() + .map(|w| w.stem) + .collect::<Vec<_>>() + .join(", ") + ); + std::process::exit(2); + }; + let Some(path) = beside(found.stem) else { + eprintln!("cannot find where this binary lives"); + std::process::exit(2); + }; + // Replaced rather than wrapped: the menu is for browsing, not + // something to sit between you and a widget you already named. + let status = std::process::Command::new(&path).args(&args[1..]).status(); + std::process::exit(match status { + Ok(s) => child_exit(s), + Err(e) => { + eprintln!("{}: {}", path.display(), e); + 2 + } + }); + } + } + + tc::maybe_help(include_str!("start_help.txt")); + let p = palette(); + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let mut selected = 0usize; + + loop { + for key in keyboard.poll() { + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "up" | "k" | "K" => selected = selected.saturating_sub(1), + "down" | "j" | "J" => selected += 1, + "enter" | "right" => { + run_widget(&mut keyboard, WIDGETS[selected.min(WIDGETS.len() - 1)].stem) + } + _ => {} + } + } + + let (w, h) = tc::size(); + if selected >= WIDGETS.len() { + selected = WIDGETS.len() - 1; + } + + let mut body = vec![tc::title("terminal toys", w, &p.accent)]; + body.push(tc::seg( + &[( + p.dim.as_str(), + { + let room = h.saturating_sub(6).max(1); + let (first, shown) = window_for(WIDGETS.len(), selected, room); + if shown < WIDGETS.len() { + // A partial list says so, rather than reading as + // the whole set with some widgets missing. + format!( + " {} widgets · showing {}-{} ↵ or → starts one, q leaves", + WIDGETS.len(), + first + 1, + first + shown + ) + } else { + format!(" {} widgets ↵ or → starts one, q leaves", WIDGETS.len()) + } + }, + )], + w - 1, + )); + body.push(String::new()); + // What is left for the list once the title, the two blanks, the + // description heading and the footer have had theirs. Drawing all + // thirteen and letting the frame cut the tail is what put the + // cursor off the bottom of a short pane. + let room = h.saturating_sub(body.len() + 5).max(1); + let (first, shown) = window_for(WIDGETS.len(), selected, room); + body.extend(rows_for(w, selected, first, shown, &p)); + body.push(String::new()); + + // What the highlighted one is for, in its own words - the rest of + // its opening paragraph, which the row has no room for. Not the + // command to run it: that is this screen's job, not the reader's. + let pick = &WIDGETS[selected]; + if h.saturating_sub(body.len()) >= 3 { + body.push(tc::seg( + &[( + p.lbl.as_str(), + format!(" ── {} ── ", pick.stem.to_uppercase()), + )], + w - 1, + )); + let tall = h.saturating_sub(body.len()) >= 12; + let about = wrap(&pick.about(), w.saturating_sub(4)); + for line in about.iter().take(if tall { 1 } else { 3 }) { + body.push(tc::seg(&[(p.dim.as_str(), format!(" {}", line))], w - 1)); + } + } + + // And what it looks like. A picture from the docs rather than the + // widget itself: starting one to look at it would ping hosts, spend + // API quota and read the whole agent transcript tree, and browsing + // a menu should cost nothing at all. Measured against the footer + // that will actually be drawn, rather than a guess at its height. + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " select".into())], + vec![(p.accent.as_str(), "↵".into()), (p.dim.as_str(), " launch".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + let foot: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + let room = h.saturating_sub(body.len() + foot.len()); + let shown = pick.sample(); + if !shown.is_empty() && room >= 6 && w >= 44 { + let rule = "─".repeat(w.saturating_sub(15).max(1)); + body.push(tc::seg( + &[ + (p.grid.as_str(), " ┌── ".into()), + (p.dim.as_str(), "example".into()), + (p.grid.as_str(), format!(" {}┐", rule)), + ], + w - 1, + )); + for line in shown.iter().take(room - 1) { + body.push(tc::seg( + &[ + (p.grid.as_str(), " │".into()), + ( + p.dim.as_str(), + line.chars().take(w.saturating_sub(4)).collect::<String>(), + ), + ], + w - 1, + )); + } + } + + while body.len() < h.saturating_sub(foot.len()) { + body.push(String::new()); + } + body.extend(foot); + body.truncate(h); + tc::draw(&body, w, h); + std::thread::sleep(Duration::from_millis(150)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_window_always_contains_the_cursor() { + // The bug this replaces: all thirteen rows were drawn and the + // frame cut the tail, so on a short pane the cursor moved onto a + // row that was not there - nothing highlighted, and Enter still + // starting whatever it was invisibly on. + for room in 1usize..14 { + for selected in 0..13 { + let (first, shown) = window_for(13, selected, room); + assert!( + selected >= first && selected < first + shown, + "room {} cursor {} fell outside {}..{}", + room, + selected, + first, + first + shown + ); + assert!(first + shown <= 13, "window ran past the list"); + } + } + } + + #[test] + fn a_list_that_fits_is_not_windowed() { + // No note, no scrolling, nothing changed for the pane sizes these + // actually run at. + assert_eq!(window_for(13, 0, 13), (0, 13)); + assert_eq!(window_for(13, 12, 20), (0, 13)); + assert_eq!(window_for(0, 0, 5), (0, 0)); + } + + #[test] + fn the_window_moves_only_as_far_as_it_must() { + // Scrolling by one when the cursor steps off the edge, rather than + // recentring: a list that jumps loses the reader's place. + assert_eq!(window_for(13, 5, 6), (0, 6)); + assert_eq!(window_for(13, 6, 6), (1, 6)); + assert_eq!(window_for(13, 12, 6), (7, 6)); + } + + + #[test] + fn every_binary_is_on_the_menu() { + // start.py globs the directory, so a new widget appears by existing. + // Here the list is compiled in, and the failure mode is a widget + // that ships without a way to find it - which linear did, for one + // commit. The manifest is the thing that knows what was built. + let manifest = include_str!("../../Cargo.toml"); + let mut built: Vec<&str> = Vec::new(); + let mut in_bin = false; + for line in manifest.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_bin = line == "[[bin]]"; + continue; + } + if in_bin { + if let Some(rest) = line.strip_prefix("name = \"") { + if let Some(name) = rest.strip_suffix('"') { + built.push(name); + } + } + } + } + assert!(built.len() > 1, "no binaries found in the manifest"); + for name in built { + // The menu does not list itself. + if name == "start" { + continue; + } + assert!( + WIDGETS.iter().any(|w| w.stem == name), + "{} is built but is not on the menu", + name + ); + } + } + + #[test] + fn every_widget_describes_itself() { + // The row and the aside both come from the widget's own help text, + // so an empty one here means a help file that lost its opening - + // which is the thing this screen is entirely made of. + for widget in WIDGETS { + assert!( + !widget.summary().trim().is_empty(), + "{} has no summary line", + widget.stem + ); + assert!( + !widget.about().trim().is_empty(), + "{} has no paragraph under its summary", + widget.stem + ); + } + } + + #[test] + fn the_aside_stops_before_the_usage_block() { + // start.py takes the paragraph under the summary and nothing more: + // what follows is the synopsis and the key list, which belong to + // --help rather than to somebody choosing a widget. + for widget in WIDGETS { + let about = widget.about(); + assert!( + !about.contains("Keys:"), + "{} carried its key list into the aside", + widget.stem + ); + assert!(about.chars().count() <= 400, "{} ran long", widget.stem); + } + } + + #[test] + fn a_sample_is_a_picture_of_the_widget() { + // Every doc page opens with a rendering, and the rendering opens + // with the same rule every widget draws across its top. A fenced + // block that does not is a shell snippet or a config listing. + let mut with_pictures = 0; + for widget in WIDGETS { + let sample = widget.sample(); + if sample.is_empty() { + continue; + } + with_pictures += 1; + assert!(sample[0].starts_with("╺━"), "{} is not a preview", widget.stem); + } + assert!( + with_pictures >= WIDGETS.len() - 1, + "only {} of {} widgets have a preview", + with_pictures, + WIDGETS.len() + ); + } + + #[test] + fn a_paragraph_breaks_at_spaces() { + assert_eq!(wrap("one two three", 7), vec!["one two", "three"]); + // A word longer than the line is cut rather than dropped. + assert_eq!(wrap("abcdefghij", 4), vec!["abcd", "efgh", "ij"]); + // Three lines at most: this is a note, not the doc page. + assert_eq!(wrap(&"word ".repeat(60), 10).len(), 3); + assert!(wrap("", 8).is_empty()); + } + + #[cfg(unix)] + #[test] + fn a_child_killed_by_signal_is_not_success() { + // `kill -s TERM $$` exits by signal, so `code()` is None. The + // previous fallback turned that into 0, which is how a crashed + // widget became a successful launch. + let status = std::process::Command::new("sh") + .args(["-c", "kill -s TERM $$"]) + .status() + .expect("spawn sh"); + assert!( + status.code().is_none(), + "expected a signal death, got {:?}", + status.code() + ); + assert_eq!(child_exit(status), 128 + 15); + } + + #[test] + fn the_list_is_in_a_settled_order() { + // Alphabetical, as start.py's sorted glob produces - so the row a + // key lands on does not move between builds. + let names: Vec<&str> = WIDGETS.iter().map(|w| w.stem).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); + } +} diff --git a/widgets/src/bin/start_help.txt b/widgets/src/bin/start_help.txt new file mode 100644 index 0000000..484cffc --- /dev/null +++ b/widgets/src/bin/start_help.txt @@ -0,0 +1,22 @@ +Every widget here, what it does, and whether it will work on this machine. + +Thirteen scripts in a directory is a list you have to already know. This is +the front door: pick one and it runs, quit it and you are back here. + + start [WIDGET] [ARGS...] + +Nothing is described twice. The name and the summary are each widget's own +first docstring line, and the note underneath is the paragraph that follows +it - both already maintained, and already checked by check.py, so a widget +cannot appear here saying something its own file does not. + +Nothing is said here about whether a widget will work. A widget that cannot +run says so itself, on its own screen, in its own words - which is where +somebody who has just tried to start it is already looking, and is the only +place that knows what it actually needs. + +Keys: up/down select, enter launches, q quits. + +start.py rescans its directory on r. This one has no directory to +rescan - the list is compiled in - so there is no recheck key here, +and none is advertised. diff --git a/widgets/src/bin/tailnet.rs b/widgets/src/bin/tailnet.rs new file mode 100644 index 0000000..914933f --- /dev/null +++ b/widgets/src/bin/tailnet.rs @@ -0,0 +1,1746 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Tailscale network: who is online, and how you are reaching them. +//! +//! A port of tailnet.py. The column that matters is PATH: a peer is either +//! DIRECT, meaning NAT traversal succeeded, or relayed through a named DERP +//! region, meaning every packet round-trips through Tailscale's own +//! infrastructure - a difference `tailscale status` does not show unless +//! you go looking for it. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::{Local, NaiveDateTime, TimeZone}; +use toys_core as tc; + +const SPARK: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +/// Cycled at runtime with n, the same way the latency monitor's i key does. +const REFRESH_CHOICES: &[f64] = &[1.0, 2.0, 5.0, 10.0, 30.0]; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +/// Seconds before an external command is given up on, from tailnet.py's longest, on `tailscale status`. +const RUN_TIMEOUT: u64 = 25; + +fn run(args: &[&str]) -> String { + // Bounded: .output() waits forever, and a wedged child used to freeze + // the poll thread with the pane still showing its last frame. + tc::run(args, RUN_TIMEOUT).unwrap_or_default() +} + +fn text(value: &serde_json::Value, key: &str) -> String { + value[key].as_str().unwrap_or("").to_string() +} + +fn bytes_at(value: &serde_json::Value, key: &str) -> u64 { + value[key].as_u64().unwrap_or(0) +} + +/// Seconds since tailscaled started. +/// +/// Its byte counters live in memory and reset with it, so RX/TX cover this +/// window rather than all time - a peer reading 0B may just predate a +/// restart. +fn daemon_uptime() -> Option<f64> { + let hz = { + let n = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if n > 0 { n as f64 } else { 100.0 } + }; + for entry in std::fs::read_dir("/proc").ok()? { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + if !name.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let comm = std::fs::read_to_string(format!("/proc/{}/comm", name)).unwrap_or_default(); + if comm.trim() != "tailscaled" { + continue; + } + let stat = std::fs::read_to_string(format!("/proc/{}/stat", name)).ok()?; + let rest = stat.rsplit_once(')')?.1; + let started: f64 = rest.split_whitespace().nth(19)?.parse().ok()?; + let uptime = std::fs::read_to_string("/proc/uptime").ok()?; + let up: f64 = uptime.split_whitespace().next()?.parse().ok()?; + return Some(up - started / hz); + } + None +} + +/// Tailnet-unique display name. +/// +/// HostName is whatever the device calls itself and is frequently useless: +/// iPads, Chromecasts and Pixels all report "localhost", and two Apple TVs +/// report the same "apple-tv". The first label of the MagicDNS name is +/// unique across the tailnet and matches what the admin console shows. +fn peer_name(peer: &serde_json::Value) -> String { + let dns = text(peer, "DNSName"); + let dns = dns.trim_end_matches('.'); + if !dns.is_empty() { + return dns.split('.').next().unwrap_or(dns).to_string(); + } + match text(peer, "HostName") { + s if s.is_empty() => "?".into(), + s => s, + } +} + +/// public | private | tailscale | other, from the address alone. +fn classify(ip: &str) -> &'static str { + let ip = ip.split('%').next().unwrap_or(ip); + if ip.contains(':') { + return if ip.to_lowercase().starts_with("fd7a:") { + "tailscale" + } else { + "other" + }; + } + let parts: Vec<&str> = ip.split('.').collect(); + let (a, b) = match ( + parts.first().and_then(|x| x.parse::<u32>().ok()), + parts.get(1).and_then(|x| x.parse::<u32>().ok()), + ) { + (Some(a), Some(b)) => (a, b), + _ => return "other", + }; + if a == 100 && (64..=127).contains(&b) { + return "tailscale"; // the CGNAT range Tailscale itself uses + } + if a == 10 || (a == 172 && (16..=31).contains(&b)) || (a == 192 && b == 168) { + return "private"; + } + if (a == 169 && b == 254) || a == 127 { + return "other"; + } + "public" +} + +/// Is an IPv4 address inside a CIDR block? +fn in_network(ip: &str, cidr: &str) -> bool { + let Some((net, bits)) = cidr.split_once('/') else { + return false; + }; + if net.contains(':') { + return false; + } + let Ok(bits) = bits.parse::<u32>() else { + return false; + }; + if bits > 32 { + return false; + } + let to_int = |a: &str| -> Option<u32> { + let parts: Vec<u32> = a.split('.').filter_map(|o| o.parse().ok()).collect(); + if parts.len() != 4 || parts.iter().any(|o| *o > 255) { + return None; + } + Some(parts.iter().enumerate().map(|(i, o)| o << (24 - 8 * i)).sum()) + }; + let mask = if bits == 0 { 0 } else { u32::MAX << (32 - bits) }; + match (to_int(ip), to_int(net)) { + (Some(a), Some(b)) => (a & mask) == (b & mask), + _ => false, + } +} + +/// Lower is better. Prefers a real LAN address over a virtual bridge. +/// +/// A peer often exposes several private endpoints, and docker0 (172.17.0.1) +/// or a k8s bridge is not the address anyone wants to copy. An address +/// inside a subnet the peer advertises is almost certainly its real LAN one. +fn lan_rank(ip: &str, routes: &[String]) -> u8 { + if routes.iter().any(|r| in_network(ip, r)) { + return 0; + } + let parts: Vec<u32> = ip.split('.').filter_map(|x| x.parse().ok()).collect(); + match (parts.first(), parts.get(1)) { + (Some(192), Some(168)) => 1, + (Some(10), _) => 2, + _ => 3, // 172.16-31: usually docker or another virtual bridge + } +} + +/// Peer LAN and public endpoints from the netmap, which needs root. +/// +/// Optional enrichment: `tailscale status` does not carry peer endpoints, so +/// without this the panel simply offers fewer addresses to copy. `sudo -n` +/// so it fails instantly rather than prompting when sudo wants a password. +fn endpoints_by_peer() -> HashMap<String, Vec<String>> { + let mut found = HashMap::new(); + let text_out = run(&["sudo", "-n", "tailscale", "debug", "netmap"]); + let data: serde_json::Value = match serde_json::from_str(&text_out) { + Ok(d) => d, + Err(_) => return found, + }; + for peer in data["Peers"].as_array().into_iter().flatten() { + let name = text(peer, "Name").trim_end_matches('.').to_string(); + if name.is_empty() { + continue; + } + found.insert( + name, + peer["Endpoints"] + .as_array() + .into_iter() + .flatten() + .filter_map(|e| e.as_str()) + .map(|e| e.rsplit_once(':').map(|(h, _)| h).unwrap_or(e).to_string()) + .collect(), + ); + } + found +} + +/// DERP region code to city, from the local map. +/// +/// A peer's home region is the Tailscale POP nearest to it, so this gives a +/// location hint without sending anyone's address to a geolocation service. +fn derp_regions() -> HashMap<String, String> { + let mut out = HashMap::new(); + let text_out = run(&["tailscale", "debug", "derp-map"]); + let data: serde_json::Value = match serde_json::from_str(&text_out) { + Ok(d) => d, + Err(_) => return out, + }; + for region in data["Regions"].as_object().into_iter().flatten().map(|(_, v)| v) { + let code = text(region, "RegionCode"); + if !code.is_empty() { + let city = text(region, "RegionName"); + out.insert(code.clone(), if city.is_empty() { code } else { city }); + } + } + out +} + +fn spark(values: &[f64], n: usize, peak: f64) -> String { + let vals = &values[values.len().saturating_sub(n)..]; + if vals.is_empty() { + return String::new(); + } + let hi = if peak > 0.0 { + peak + } else { + vals.iter().cloned().fold(0.0f64, f64::max) + }; + if hi <= 0.0 { + return "·".repeat(vals.len()); + } + vals.iter() + .map(|v| SPARK[(((v / hi) * 7.99) as usize).min(7)]) + .collect() +} + +fn ago(s: f64) -> String { + let s = s.max(0.0) as i64; + if s < 3600 { + format!("{}m ago", s / 60) + } else if s < 172_800 { + format!("{}h ago", s / 3600) + } else { + format!("{}d ago", s / 86400) + } +} + +/// A byte count in exactly five cells, so columns stay aligned. +fn human(n: u64) -> String { + let mut v = n as f64; + for unit in ["B", "K", "M", "G", "T"] { + if v < 1024.0 { + let body = if v >= 10.0 || unit == "B" { + format!("{:.0}{}", v, unit) + } else { + format!("{:.1}{}", v, unit) + }; + return format!("{:>5}", body); + } + v /= 1024.0; + } + format!("{:>5}", format!("{:.0}P", v)) +} + +/// A per-second byte rate in six cells. +fn rate(v: f64) -> String { + format!("{:>5}/s", human(v as u64).trim()) +} + +fn parse_iso(iso: &str) -> Option<NaiveDateTime> { + if iso.len() < 19 || iso.starts_with("0001-01-01") { + return None; + } + NaiveDateTime::parse_from_str(&iso[..19], "%Y-%m-%dT%H:%M:%S").ok() +} + +/// A span of seconds in three or four cells: 45s, 12m, 6h, 3d. +fn brief(s: f64) -> String { + let s = s.max(0.0) as i64; + if s < 90 { + format!("{}s", s) + } else if s < 5400 { + format!("{}m", s / 60) + } else if s < 172_800 { + format!("{}h", s / 3600) + } else { + format!("{}d", s / 86400) + } +} + +/// The age of an ISO-8601 timestamp, coarsely. +fn seen(iso: &str) -> String { + let Some(at) = parse_iso(iso) else { + return " -".into(); + }; + let s = (chrono::Utc::now().naive_utc() - at).num_seconds().max(0); + if s < 90 { "now".into() } else { brief(s as f64) } +} + +/// ISO-8601 to a readable local time, or nothing when unset. +fn stamp(iso: &str) -> String { + let Some(at) = parse_iso(iso) else { + return String::new(); + }; + match Local.from_utc_datetime(&at).format("%Y-%m-%d %H:%M").to_string() { + s => s, + } +} + +/// The addresses worth copying for a peer, as (label, value) pairs. +fn addresses(peer: &serde_json::Value, eps: &HashMap<String, Vec<String>>) -> Vec<(String, String)> { + let mut out = Vec::new(); + let ips: Vec<String> = peer["TailscaleIPs"] + .as_array() + .into_iter() + .flatten() + .filter_map(|i| i.as_str().map(String::from)) + .collect(); + let v4: Vec<&String> = ips.iter().filter(|i| !i.contains(':')).collect(); + let v6: Vec<&String> = ips.iter().filter(|i| i.contains(':')).collect(); + if let Some(first) = v4.first() { + out.push(("Tailscale IP".into(), (*first).clone())); + } + let dns = text(peer, "DNSName").trim_end_matches('.').to_string(); + if !dns.is_empty() { + out.push(("MagicDNS name".into(), dns.clone())); + } + + let (mut pub_ips, mut priv_ips): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new()); + let cur = text(peer, "CurAddr"); + let cur = cur.rsplit_once(':').map(|(h, _)| h).unwrap_or(&cur).to_string(); + if !cur.is_empty() && classify(&cur) == "public" { + pub_ips.push(cur); + } + for ip in eps.get(&dns).into_iter().flatten() { + match classify(ip) { + "public" if !pub_ips.contains(ip) => pub_ips.push(ip.clone()), + "private" if !priv_ips.contains(ip) => priv_ips.push(ip.clone()), + _ => {} + } + } + // PrimaryRoutes only: AllowedIPs also carries 0.0.0.0/0 for exit nodes, + // which would match every address and defeat the ranking entirely. + let routes = primary_routes(peer); + priv_ips.sort_by_key(|i| lan_rank(i, &routes)); + if let Some(first) = pub_ips.first() { + out.push(("Public IP".into(), first.clone())); + } + if let Some(first) = priv_ips.first() { + out.push(("Private IP (LAN)".into(), first.clone())); + if let Some(second) = priv_ips.get(1) { + out.push(("Other private IP".into(), second.clone())); + } + } + if let Some(first) = v6.first() { + out.push(("Tailscale IPv6".into(), (*first).clone())); + } + out +} + +fn primary_routes(peer: &serde_json::Value) -> Vec<String> { + peer["PrimaryRoutes"] + .as_array() + .into_iter() + .flatten() + .filter_map(|r| r.as_str()) + .filter(|r| *r != "0.0.0.0/0" && *r != "::/0") + .map(String::from) + .collect() +} + +/// Pings whichever peer is selected, keeping per-peer history. +/// +/// Probing every peer continuously would mean two dozen ping processes for +/// data nobody is looking at, so exactly one runs at a time and follows the +/// selection. History is kept per peer, so returning to one still shows its +/// earlier samples. +#[derive(Default)] +struct Prober { + samples: HashMap<String, Vec<Option<f64>>>, + want: Option<(String, String)>, + pid: Option<i32>, + /// Why the selected peer has no latency, when the reason is this + /// widget's own rather than the network's. `tailscale` is checked at + /// startup and `ping` is not, so on a host with one and not the other + /// the spawn failed, was thrown away, and was retried every two seconds + /// for as long as the widget was up - while an empty history suppressed + /// the whole latency section, which is what a peer nobody had pinged + /// yet looks like too. + err: String, +} + +fn rtt_of(line: &str) -> Option<f64> { + let at = line.find("time=")? + 5; + let rest = &line[at..]; + let end = rest + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(rest.len()); + rest[..end].parse().ok() +} + +fn prober_loop(shared: Arc<Mutex<Prober>>) { + loop { + let target = shared.lock().ok().and_then(|g| g.want.clone()); + let Some((machine, ip)) = target else { + // Nothing selected to probe - this machine, or a peer that is + // offline. Nothing has failed, and a reason left over from the + // last peer would be read as belonging to this one. + if let Ok(mut g) = shared.lock() { + g.err.clear(); + } + std::thread::sleep(Duration::from_millis(400)); + continue; + }; + let child = std::process::Command::new("ping") + .args(["-n", "-O", "-i", "1", &ip]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => { + let why = format!("ping will not start: {}", e); + if let Ok(mut g) = shared.lock() { + g.err = why; + } + std::thread::sleep(Duration::from_secs(2)); + continue; + } + }; + // It started, so whatever stopped it last time is over. + if let Ok(mut g) = shared.lock() { + g.err.clear(); + g.pid = Some(child.id() as i32); + } + if let Some(stdout) = child.stdout.take() { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let mut g = match shared.lock() { + Ok(g) => g, + Err(_) => return, + }; + // The selection moved on; this ping is for a peer nobody is + // looking at any more. + if g.want.as_ref().map(|w| w.0.clone()) != Some(machine.clone()) { + break; + } + let hist = g.samples.entry(machine.clone()).or_default(); + if let Some(rtt) = rtt_of(&line) { + hist.push(Some(rtt)); + } else if line.contains("no answer yet") || line.contains("Unreachable") { + hist.push(None); + } + if hist.len() > 120 { + let drop = hist.len() - 120; + hist.drain(..drop); + } + } + } + // Whether the selection moved on while that ping was running. If it + // did, the stream ended because we stopped it and there is nothing + // to report; if it did not, ping died on its own - which used to + // restart it immediately, in silence, as fast as the kernel would + // spawn it, for a peer whose latency section stayed empty. + let moved_on = shared + .lock() + .map(|g| g.want.as_ref().map(|w| w.0.as_str()) != Some(machine.as_str())) + .unwrap_or(true); + let _ = child.kill(); + let status = child.wait(); + if let Ok(mut g) = shared.lock() { + g.pid = None; + if !moved_on { + g.err = match &status { + Ok(s) => format!("ping stopped ({})", s), + Err(e) => format!("ping stopped: {}", e), + }; + } + } + if !moved_on { + std::thread::sleep(Duration::from_secs(2)); + } + } +} + +fn watch(shared: &Arc<Mutex<Prober>>, machine: &str, ip: Option<String>) { + let kill = { + let Ok(mut g) = shared.lock() else { return }; + if g.want.as_ref().is_some_and(|w| w.0 == machine) { + return; + } + g.want = ip.map(|ip| (machine.to_string(), ip)); + g.pid.take() + }; + // The running ping belongs to the peer that was selected a moment ago, + // so it is stopped rather than left to finish into a history nobody + // will read. + if let Some(pid) = kill { + unsafe { libc::kill(pid, libc::SIGTERM) }; + } +} + +#[derive(Default)] +struct State { + data: Option<serde_json::Value>, + endpoints: HashMap<String, Vec<String>>, + err: String, + rates: HashMap<String, Vec<(f64, f64)>>, + counters: HashMap<String, (u64, u64, f64)>, + endpoints_at: f64, + /// When `data` last came back parseable. A failed poll keeps the rows it + /// cannot replace, so this is what says how old they are. + data_at: f64, +} + +/// Turn cumulative byte counters into per-second rates. +fn sample_rates(state: &mut State, data: &serde_json::Value, history: usize) { + let at = now(); + for peer in data["Peer"].as_object().into_iter().flatten().map(|(_, v)| v) { + let key = peer_name(peer); + let (rx, tx) = (bytes_at(peer, "RxBytes"), bytes_at(peer, "TxBytes")); + let prev = state.counters.insert(key.clone(), (rx, tx, at)); + let Some((was_rx, was_tx, when)) = prev else { + continue; + }; + let dt = at - when; + if dt <= 0.0 { + continue; + } + // A tailscaled restart zeroes the counters; report no traffic rather + // than a large negative spike. + let drx = rx.saturating_sub(was_rx) as f64 / dt; + let dtx = tx.saturating_sub(was_tx) as f64 / dt; + let hist = state.rates.entry(key).or_default(); + hist.push((drx, dtx)); + if hist.len() > history { + let drop = hist.len() - history; + hist.drain(..drop); + } + } +} + +struct Palette { + online: String, + offline: String, + offline_lit: String, + direct: String, + relay: String, + dim: String, + txt: String, + lbl: String, + accent: String, + route: String, + exit: String, +} + +fn palette() -> Palette { + Palette { + online: tc::rgb(90, 240, 160), + offline: tc::rgb(120, 130, 150), + offline_lit: tc::rgb(144, 152, 169), + direct: tc::rgb(90, 240, 160), + relay: tc::rgb(255, 190, 90), + dim: tc::rgb(127, 147, 172), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + accent: tc::rgb(120, 200, 255), + route: tc::rgb(200, 160, 255), + exit: tc::rgb(255, 140, 200), + } +} + +/// Live throughput for peers that are actually moving data. +fn activity_rows( + rates: &HashMap<String, Vec<(f64, f64)>>, + w: usize, + limit: usize, + p: &Palette, +) -> Vec<String> { + let mut active: Vec<(f64, &String, &Vec<(f64, f64)>)> = rates + .iter() + .filter_map(|(name, hist)| { + if hist.is_empty() { + return None; + } + let recent = &hist[hist.len().saturating_sub(30)..]; + let peak = recent.iter().map(|(r, t)| r + t).fold(0.0f64, f64::max); + // Ignore keepalives: a tailnet is never entirely silent. + if peak < 64.0 { + return None; + } + Some((peak, name, hist)) + }) + .collect(); + active.sort_by(|a, b| b.0.total_cmp(&a.0)); + if active.is_empty() { + return vec![tc::seg( + &[(p.dim.as_str(), " no peer traffic in the last few minutes".into())], + w - 1, + )]; + } + let sw = (w.saturating_sub(42)).clamp(8, 28); + let peak = active[0].0; + active + .iter() + .take(limit) + .map(|(_, name, hist)| { + let rx: Vec<f64> = hist.iter().map(|(r, _)| *r).collect(); + let tx: Vec<f64> = hist.iter().map(|(_, t)| *t).collect(); + tc::seg( + &[ + ( + p.txt.as_str(), + format!( + " {}", + tc::pad(&name.chars().take(18).collect::<String>(), 19) + ), + ), + (p.online.as_str(), format!("↓{}", spark(&rx, sw, peak))), + (p.relay.as_str(), format!(" ↑{}", spark(&tx, sw, peak))), + ( + p.dim.as_str(), + format!( + " {} {}", + rate(*rx.last().unwrap_or(&0.0)), + rate(*tx.last().unwrap_or(&0.0)) + ), + ), + ], + w - 1, + ) + }) + .collect() +} + +fn main() { + tc::maybe_help(include_str!("tailnet_help.txt")); + let cfg = tc::load_config("tailnet"); + let mut refresh = tc::poll_secs(tc::cfg_f64(&cfg, "refresh", 2.0), 2.0); + let history = tc::cfg_usize(&cfg, "history", 180); + let args: Vec<String> = std::env::args().skip(1).collect(); + if args.len() >= 2 && (args[0] == "-n" || args[0] == "--refresh") { + refresh = tc::poll_secs(args[1].parse().unwrap_or(2.0), 2.0).max(1.0); + } + + let absent = tc::missing(&["tailscale"]); + if !absent.is_empty() { + tc::cannot_start( + "tailnet", + &absent, + &[ + "Everything here comes from the local tailscaled through its", + "own CLI: who is online, whether each peer is direct or", + "relayed, and the byte counters the WireGuard engine keeps.", + "", + "There is no other source for any of it, and nothing here is", + "sent anywhere - the DERP region names come from the local", + "map rather than from a geolocation service.", + ], + "see https://tailscale.com/download", + ); + return; + } + + let p = palette(); + let state = Arc::new(Mutex::new(State::default())); + let prober = Arc::new(Mutex::new(Prober::default())); + let wake = Arc::new((Mutex::new(false), Condvar::new())); + let refresh_now = Arc::new(Mutex::new(refresh)); + + let poller = Arc::clone(&state); + let poller_wake = Arc::clone(&wake); + let poller_refresh = Arc::clone(&refresh_now); + std::thread::spawn(move || loop { + let out = run(&["tailscale", "status", "--json"]); + let parsed: Option<serde_json::Value> = serde_json::from_str(&out).ok(); + // The netmap needs sudo and changes slowly, so it is asked for far + // less often than the status is. + let want_eps = poller + .lock() + .map(|g| now() - g.endpoints_at > 60.0) + .unwrap_or(false); + let eps = if want_eps { Some(endpoints_by_peer()) } else { None }; + if let Ok(mut g) = poller.lock() { + match &parsed { + None => g.err = "tailscale CLI unavailable or not logged in".into(), + Some(d) => { + g.err.clear(); + sample_rates(&mut g, d, history); + g.data = Some(d.clone()); + g.data_at = now(); + } + } + if let Some(eps) = eps { + if !eps.is_empty() { + g.endpoints = eps; + } + g.endpoints_at = now(); + } + } + let wait = tc::poll_secs(poller_refresh.lock().map(|g| *g).unwrap_or(2.0), 2.0); + let (lock, cond) = &*poller_wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(wait)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + *asked = false; + }); + + let probing = Arc::clone(&prober); + std::thread::spawn(move || prober_loop(probing)); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + let (mut hide_offline, mut show_graph) = (false, true); + let (mut selected, mut scroll, mut visible) = (0usize, 0usize, 1usize); + // None, "copy" or "info". + let mut view: Option<&'static str> = None; + // Where the copy list was opened from, so closing it puts you back + // there. It used to drop to the machine list whichever screen you had + // been reading, which loses your place for the sake of one keystroke. + let mut copy_from: Option<&'static str> = None; + let mut note: (String, f64) = (String::new(), 0.0); + let mut listed: Vec<serde_json::Value> = Vec::new(); + let derp = derp_regions(); + + let nudge = |wake: &Arc<(Mutex<bool>, Condvar)>| { + let (lock, cond) = &**wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + }; + + loop { + let (data, eps_now, err, rates, data_at) = match state.lock() { + Ok(g) => ( + g.data.clone(), + g.endpoints.clone(), + g.err.clone(), + g.rates.clone(), + g.data_at, + ), + Err(_) => return, + }; + + for key in keyboard.poll() { + if view.is_some() { + match key.as_str() { + // Left comes back out, the way it does everywhere + // else: right and enter go in, left and esc come + // out, and no widget needs a letter of its own for + // it. `i` used to open this and no longer does. + "left" | "esc" => { + view = copy_from.take(); + } + // q quits from here too. It used to close the view + // instead, which is its own kind of trap: the key + // appears to do nothing to a widget you are trying + // to leave, and every other widget quits on it. + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "c" | "C" => { + view = if view != Some("copy") { + copy_from = view; + Some("copy") + } else { + copy_from.take() + } + } + digit + if view == Some("copy") + && digit.len() == 1 + && digit.chars().all(|c| c.is_ascii_digit()) + && !listed.is_empty() => + { + let pairs = + addresses(&listed[selected.min(listed.len() - 1)], &eps_now); + let at = digit.parse::<usize>().unwrap_or(0); + if at >= 1 && at <= pairs.len() { + let (label, value) = &pairs[at - 1]; + note = ( + if tc::clipboard(value) { + format!("✓ copied {}", label.to_lowercase()) + } else { + "! no clipboard; select the text with the mouse".into() + }, + now() + 3.0, + ); + } + } + _ => {} + } + continue; + } + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "r" | "R" => nudge(&wake), + "o" | "O" => { + hide_offline = !hide_offline; + selected = 0; + } + "g" | "G" => show_graph = !show_graph, + // i, as latency names the same key. n was this widget's + // own letter for it and meant nothing to a reader coming + // from the widget beside it; i was free here because the + // info screen moved to the arrows. + "i" | "I" => { + if let Ok(mut g) = refresh_now.lock() { + *g = tc::cycle(REFRESH_CHOICES, *g); + } + nudge(&wake); // apply the new interval immediately + } + "up" => selected = selected.saturating_sub(1), + "down" => selected += 1, + "pgup" => selected = selected.saturating_sub(visible), + "pgdn" => selected += visible, + "home" => selected = 0, + "end" => selected = listed.len().saturating_sub(1), + "c" | "C" => { + if !listed.is_empty() { + view = Some("copy"); + copy_from = None; + note = (String::new(), 0.0); + } + } + "right" | "enter" => { + if !listed.is_empty() { + view = Some("info"); + copy_from = None; + } + } + _ => {} + } + } + + let (w, h) = tc::size(); + let interval = refresh_now.lock().map(|g| *g).unwrap_or(2.0); + if !note.0.is_empty() && now() > note.1 { + note = (String::new(), 0.0); + } + let mut rows = vec![tc::title("tailnet", w, &p.accent)]; + let Some(data) = data else { + rows.push(tc::seg( + &[( + p.relay.as_str(), + format!(" {}", if err.is_empty() { "connecting…" } else { &err }), + )], + w - 1, + )); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(400)); + continue; + }; + + // A poll that fails after a good one keeps the rows it cannot + // replace, which is the right thing to do and used to be done in + // silence: `err` was only ever drawn in place of the peer list, so + // once there was a list to draw instead, a tailnet that had stopped + // answering went on looking exactly like one where nothing had + // changed. The rows stay, and say what they are. + let stale = if err.is_empty() { + String::new() + } else { + format!( + "polling is failing, these rows are cached, {} old ({})", + brief(now() - data_at), + err + ) + }; + if !stale.is_empty() { + rows.push(tc::seg(&[(p.relay.as_str(), format!(" ! {}", stale))], w - 1)); + } + + let me = data["Self"].clone(); + let peers: Vec<serde_json::Value> = data["Peer"] + .as_object() + .into_iter() + .flatten() + .map(|(_, v)| v.clone()) + .collect(); + let online: Vec<&serde_json::Value> = peers + .iter() + .filter(|x| x["Online"].as_bool().unwrap_or(false)) + .collect(); + let direct = online.iter().filter(|x| !text(x, "CurAddr").is_empty()).count(); + let routers: Vec<&serde_json::Value> = peers + .iter() + .filter(|x| !primary_routes(x).is_empty()) + .collect(); + let exits: Vec<&serde_json::Value> = peers + .iter() + .filter(|x| x["ExitNode"].as_bool().unwrap_or(false)) + .collect(); + + let my_name = text(&me, "DNSName"); + rows.push(tc::seg( + &[ + ( + p.txt.as_str(), + format!( + " {}", + my_name.trim_end_matches('.').split('.').next().unwrap_or("") + ), + ), + ( + p.dim.as_str(), + format!( + " {}", + me["TailscaleIPs"][0].as_str().unwrap_or("?") + ), + ), + (p.dim.as_str(), format!(" {}", text(&data, "MagicDNSSuffix"))), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.online.as_str(), format!(" {} online", online.len())), + (p.dim.as_str(), format!(" / {} peers", peers.len())), + (p.direct.as_str(), format!(" {} direct", direct)), + ( + p.relay.as_str(), + format!(" {} relayed", online.len() - direct), + ), + (p.dim.as_str(), format!(" every {}s", interval)), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.route.as_str(), format!(" {} advertising routes", routers.len())), + ( + p.exit.as_str(), + format!( + " exit node: {}", + match exits.first() { + Some(x) => peer_name(x), + None => "none".into(), + } + ), + ), + ], + w - 1, + )); + rows.push(String::new()); + + if let (Some(which), false) = (view, listed.is_empty()) { + let chosen = listed[selected.min(listed.len() - 1)].clone(); + let body = if which == "info" { + let users: HashMap<String, serde_json::Value> = data["User"] + .as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let (latency, probe_err) = prober + .lock() + .ok() + .map(|g| { + ( + g.samples.get(&peer_name(&chosen)).cloned().unwrap_or_default(), + g.err.clone(), + ) + }) + .unwrap_or_default(); + info_overlay( + &chosen, &eps_now, &users, w, h, &rates, &latency, &probe_err, &stale, &derp, + &p, + ) + } else { + copy_overlay(&chosen, &eps_now, w, h, ¬e.0, &p) + }; + tc::draw(&body, w, h); + std::thread::sleep(Duration::from_millis(100)); + continue; + } + + if show_graph { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── LIVE THROUGHPUT ── ".into()), + (p.dim.as_str(), "peers moving data".into()), + ], + w - 1, + )); + rows.extend(activity_rows(&rates, w, 4, &p)); + rows.push(String::new()); + } + + let wide = w >= 62; + // Machine names are long; spend spare width on them, not padding. + let namew = if wide { + (w.saturating_sub(45)).clamp(16, 32) + } else { + w.saturating_sub(22).max(12) + }; + let mut head = format!(" {} {:<8} {:<7}", tc::pad("MACHINE", namew + 1), "OS", "PATH"); + if wide { + head += &format!(" {:>5} {:>5} {:>5}", "RX", "TX", "SEEN"); + } + rows.push(tc::seg(&[(p.lbl.as_str(), tc::pad(&head, w - 1))], w - 1)); + if wide { + let span = daemon_uptime(); + rows.push(tc::seg( + &[( + p.dim.as_str(), + format!( + " rx/tx = this host ↔ peer, since tailscaled started{}", + match span { + Some(s) => format!(" {}", ago(s)), + None => String::new(), + } + ), + )], + w - 1, + )); + } + + if let Some(sel_peer) = listed.get(selected.min(listed.len().saturating_sub(1))) { + let ip4 = sel_peer["TailscaleIPs"] + .as_array() + .into_iter() + .flatten() + .filter_map(|i| i.as_str()) + .find(|i| !i.contains(':')) + .map(String::from); + // Nothing is learned by pinging ourselves, and the round trip + // would read as a suspiciously good link. + let live = sel_peer["Online"].as_bool().unwrap_or(false) + && !sel_peer["_self"].as_bool().unwrap_or(false); + watch( + &prober, + &peer_name(sel_peer), + if live { ip4 } else { None }, + ); + } + + let mut sorted = peers.clone(); + sorted.sort_by_key(|x| { + ( + !x["Online"].as_bool().unwrap_or(false), + text(x, "CurAddr").is_empty(), + std::cmp::Reverse(bytes_at(x, "RxBytes") + bytes_at(x, "TxBytes")), + ) + }); + listed = sorted + .into_iter() + .filter(|x| !(hide_offline && !x["Online"].as_bool().unwrap_or(false))) + .collect(); + // This machine belongs in the list of machines - it was only ever in + // the header - but never in the counts above it: "3 direct, 2 + // relayed" describes connections out of here, and there is no + // connection from here to here. It pins to the top rather than + // sorting by traffic, because where you are is not a ranking. + if !me.is_null() { + let mut mine = me.clone(); + mine["_self"] = serde_json::Value::Bool(true); + listed.insert(0, mine); + } + if !listed.is_empty() && selected >= listed.len() { + selected = listed.len() - 1; + } + visible = h.saturating_sub(rows.len() + 2).max(1); + if selected < scroll { + scroll = selected; + } else if selected >= scroll + visible { + scroll = selected - visible + 1; + } + scroll = scroll.min(listed.len().saturating_sub(visible)); + + for (idx, peer) in listed.iter().enumerate().skip(scroll).take(visible) { + if rows.len() >= h.saturating_sub(2) { + break; + } + let mine = peer["_self"].as_bool().unwrap_or(false); + let up = mine || peer["Online"].as_bool().unwrap_or(false); + let here = idx == selected; + let tint = if here { tc::bg(28, 44, 62) } else { String::new() }; + let c = |colour: &str| { + // offline is what a peer that is not up is drawn in, and it + // measured 3.67 on this tint - the worst in the widget. + let colour = if !tint.is_empty() && colour == p.offline { + p.offline_lit.as_str() + } else { + colour + }; + format!("{}{}", tint, colour) + }; + let path_direct = !text(peer, "CurAddr").is_empty(); + // "this" rather than DIRECT or a relay name: the path column + // answers how the traffic gets there, and for this machine it + // does not go anywhere. + let path = if mine { + "this".to_string() + } else if path_direct { + "DIRECT".to_string() + } else { + match text(peer, "Relay") { + s if s.is_empty() => "?".into(), + s => s, + } + }; + let name = peer_name(peer); + let mut line = vec![ + ( + c(if mine { + &p.accent + } else if up { + &p.online + } else { + &p.offline + }), + format!( + "{}{} ", + if here { "▸" } else { " " }, + if mine { '◆' } else if up { '●' } else { '○' } + ), + ), + ( + c(if up { &p.txt } else { &p.offline }), + tc::pad(&name.chars().take(namew - 1).collect::<String>(), namew), + ), + ( + c(&p.dim), + format!("{:<8}", text(peer, "OS").chars().take(8).collect::<String>()), + ), + ( + c(if mine { + &p.accent + } else if path_direct { + &p.direct + } else { + &p.relay + }), + format!("{:<7}", if up { path.as_str() } else { "-" }), + ), + ]; + if wide { + line.push(( + c(&p.dim), + format!( + " {} {}", + human(bytes_at(peer, "RxBytes")), + human(bytes_at(peer, "TxBytes")) + ), + )); + line.push(( + c(&p.dim), + format!( + " {:>5}", + if up { "now".to_string() } else { seen(&text(peer, "LastSeen")) } + ), + )); + } + if !primary_routes(peer).is_empty() { + line.push((c(&p.route), " ⇄".into())); + } + if here { + line.push((tint.clone(), " ".repeat(w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + + while rows.len() < h.saturating_sub(2) { + rows.push(String::new()); + } + if let Some(first) = routers.first() { + let rts = primary_routes(first); + rows.push(tc::seg( + &[ + (p.route.as_str(), " ⇄ ".into()), + (p.dim.as_str(), format!("{} routes ", text(first, "HostName"))), + (p.txt.as_str(), rts.iter().take(2).cloned().collect::<Vec<_>>().join(", ")), + ( + p.dim.as_str(), + if rts.len() > 2 { + format!(" +{} more", rts.len() - 2) + } else { + String::new() + }, + ), + ], + w - 1, + )); + } + let hints: Vec<Vec<(&str, String)>> = vec![ + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " select".into())], + vec![ + (p.accent.as_str(), "→".into()), + (p.dim.as_str(), "/↵ info".into()), + ], + vec![(p.dim.as_str(), "[c]opy".into())], + vec![(p.dim.as_str(), "[g]raph".into())], + vec![(p.dim.as_str(), "[o]ffline".into())], + // The current value is worth the three cells: this key cycles + // rather than toggles, so "[i]nterval" alone would not say what + // pressing it is about to change from. + vec![(p.dim.as_str(), format!("[i]nterval {}s", interval))], + vec![(p.dim.as_str(), "[r]efresh".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + for line in tc::pack_hints(&hints, w - 2, " ") { + rows.push(format!(" {}", line)); + } + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(300)); + } +} + +/// Everything known about one machine, including every address. +#[allow(clippy::too_many_arguments)] +fn info_overlay( + peer: &serde_json::Value, + eps: &HashMap<String, Vec<String>>, + users: &HashMap<String, serde_json::Value>, + w: usize, + h: usize, + rates: &HashMap<String, Vec<(f64, f64)>>, + latency: &[Option<f64>], + probe_err: &str, + stale: &str, + derp: &HashMap<String, String>, + p: &Palette, +) -> Vec<String> { + let mut rows = vec![tc::title("machine info", w, &p.accent)]; + // Every field below is out of the same cached status as the list behind + // this view, so it carries the same warning. + if !stale.is_empty() { + rows.push(tc::seg(&[(p.relay.as_str(), format!(" ! {}", stale))], w - 1)); + } + let dns = text(peer, "DNSName").trim_end_matches('.').to_string(); + let owner = users + .get(&peer["UserID"].as_i64().unwrap_or(0).to_string()) + .map(|u| text(u, "LoginName")) + .unwrap_or_default(); + + macro_rules! field { + ($label:expr, $value:expr, $colour:expr) => {{ + let value: String = $value; + if !value.is_empty() { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {:<13}", $label)), + ($colour, value), + ], + w - 1, + )); + } + }}; + } + + field!("machine", peer_name(peer), p.accent.as_str()); + field!("dns name", dns.clone(), p.txt.as_str()); + let hostname = text(peer, "HostName"); + if hostname != peer_name(peer) && !hostname.is_empty() { + field!( + "hostname", + format!("{} (self-reported)", hostname), + p.dim.as_str() + ); + } + field!("os", text(peer, "OS"), p.txt.as_str()); + let region = text(peer, "Relay"); + if !region.is_empty() { + field!( + "region", + match derp.get(®ion) { + Some(city) => format!("{} — {}", region, city), + None => region.clone(), + }, + p.route.as_str() + ); + } + field!("owner", owner, p.txt.as_str()); + field!( + "tags", + peer["Tags"] + .as_array() + .into_iter() + .flatten() + .filter_map(|t| t.as_str()) + .collect::<Vec<_>>() + .join(", "), + p.route.as_str() + ); + rows.push(String::new()); + + let up = peer["Online"].as_bool().unwrap_or(false); + field!( + "status", + if up { "online" } else { "offline" }.to_string(), + if up { p.online.as_str() } else { p.offline.as_str() } + ); + let cur = text(peer, "CurAddr"); + if !cur.is_empty() { + field!("path", format!("DIRECT via {}", cur), p.direct.as_str()); + } else { + let relay = match region.as_str() { + "" => "?".to_string(), + s => s.to_string(), + }; + let city = derp.get(&relay).cloned().unwrap_or_default(); + field!( + "path", + format!( + "relayed through DERP {}{}", + relay, + if city.is_empty() { String::new() } else { format!(" ({})", city) } + ), + p.relay.as_str() + ); + } + field!( + "rx / tx", + format!( + "{} / {}", + human(bytes_at(peer, "RxBytes")).trim(), + human(bytes_at(peer, "TxBytes")).trim() + ), + p.txt.as_str() + ); + field!("handshake", stamp(&text(peer, "LastHandshake")), p.txt.as_str()); + let last = stamp(&text(peer, "LastSeen")); + field!( + "last seen", + if last.is_empty() { + if up { "connected".to_string() } else { "-".to_string() } + } else { + last + }, + p.txt.as_str() + ); + field!("added", stamp(&text(peer, "Created")), p.txt.as_str()); + rows.push(String::new()); + + rows.push(tc::seg(&[(p.lbl.as_str(), " addresses".into())], w - 1)); + for ip in peer["TailscaleIPs"].as_array().into_iter().flatten() { + field!( + " tailscale", + ip.as_str().unwrap_or("").to_string(), + p.accent.as_str() + ); + } + let (mut pub_ips, mut priv_ips, mut other) = (Vec::new(), Vec::new(), Vec::new()); + let cur_host = cur.rsplit_once(':').map(|(h, _)| h).unwrap_or(&cur).to_string(); + if !cur_host.is_empty() { + pub_ips.push(cur_host); + } + for ip in eps.get(&dns).into_iter().flatten() { + let bucket = match classify(ip) { + "public" => &mut pub_ips, + "private" => &mut priv_ips, + _ => &mut other, + }; + if !bucket.contains(ip) { + bucket.push(ip.clone()); + } + } + let routes = primary_routes(peer); + priv_ips.sort_by_key(|i| lan_rank(i, &routes)); + for ip in &pub_ips { + field!(" public", ip.clone(), p.txt.as_str()); + } + for ip in &priv_ips { + let tag = if routes.iter().any(|r| in_network(ip, r)) { + " (in advertised subnet)" + } else { + "" + }; + field!(" private", format!("{}{}", ip, tag), p.txt.as_str()); + } + for ip in &other { + field!(" other", ip.clone(), p.dim.as_str()); + } + if eps.is_empty() { + rows.push(tc::seg( + &[( + p.dim.as_str(), + " endpoints need sudo; only the current path is shown".into(), + )], + w - 1, + )); + } + + if !routes.is_empty() { + rows.push(String::new()); + rows.push(tc::seg(&[(p.lbl.as_str(), " advertises".into())], w - 1)); + for r in routes.iter().take(6) { + rows.push(tc::seg(&[(p.route.as_str(), format!(" {}", r))], w - 1)); + } + if routes.len() > 6 { + rows.push(tc::seg( + &[(p.dim.as_str(), format!(" +{} more", routes.len() - 6))], + w - 1, + )); + } + } + + // Exactly the peers the main loop hands to the prober: not this machine, + // and not one that is offline. "probing…" sat on this machine's own row + // for as long as you left it open, for a ping that is never sent. + let probed = up && !peer["_self"].as_bool().unwrap_or(false); + let pings: Vec<f64> = latency.iter().filter_map(|x| *x).collect(); + if !latency.is_empty() { + rows.push(String::new()); + let loss = 100.0 * (latency.len() - pings.len()) as f64 / latency.len() as f64; + if !pings.is_empty() { + let mut ordered = pings.clone(); + ordered.sort_by(f64::total_cmp); + let jit = if pings.len() > 1 { + pings.windows(2).map(|x| (x[1] - x[0]).abs()).sum::<f64>() + / (pings.len() - 1) as f64 + } else { + 0.0 + }; + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " latency ".into()), + ( + p.dim.as_str(), + format!("(icmp over tailscale, {} samples)", latency.len()), + ), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " now ".into()), + (p.txt.as_str(), format!("{:>7.2}ms", pings[pings.len() - 1])), + (p.dim.as_str(), " avg ".into()), + ( + p.txt.as_str(), + format!("{:>7.2}ms", pings.iter().sum::<f64>() / pings.len() as f64), + ), + (p.dim.as_str(), " med ".into()), + (p.txt.as_str(), format!("{:>7.2}ms", ordered[ordered.len() / 2])), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " min ".into()), + (p.txt.as_str(), format!("{:>7.2}ms", ordered[0])), + (p.dim.as_str(), " max ".into()), + (p.txt.as_str(), format!("{:>7.2}ms", ordered[ordered.len() - 1])), + (p.dim.as_str(), " jit ".into()), + (p.txt.as_str(), format!("{:>7.2}ms", jit)), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " loss".into()), + ( + if loss == 0.0 { p.online.as_str() } else { p.relay.as_str() }, + format!("{:>7.1}%", loss), + ), + ], + w - 1, + )); + let (lo, hi) = (ordered[0], ordered[ordered.len() - 1]); + let span = if hi > lo { hi - lo } else { 1.0 }; + let sw = w.saturating_sub(8).max(10); + let mut marks: Vec<(&str, String)> = vec![(p.dim.as_str(), " ".into())]; + for v in latency.iter().rev().take(sw).rev() { + match v { + None => marks.push((p.relay.as_str(), "×".into())), + Some(v) => marks.push(( + p.online.as_str(), + SPARK[((((v - lo) / span) * 7.99) as usize).min(7)].to_string(), + )), + } + } + rows.push(tc::seg(&marks, w - 1)); + } else { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " latency ".into()), + (p.relay.as_str(), "no replies".into()), + ], + w - 1, + )); + } + } else if probed && probe_err.is_empty() { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " latency ".into()), + (p.dim.as_str(), "probing…".into()), + ], + w - 1, + )); + } + // Why there is nothing to report, when the reason is this widget's own. + // It follows the figures rather than replacing them, the way latency's + // rows do: the samples taken before the probe broke are still true, and + // still the last thing this peer was known to be doing. + if probed && !probe_err.is_empty() { + if latency.is_empty() { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " latency ".into()), + (p.relay.as_str(), probe_err.to_string()), + ], + w - 1, + )); + } else { + rows.push(tc::seg( + &[(p.relay.as_str(), format!(" ! {}", probe_err))], + w - 1, + )); + } + } + + if let Some(hist) = rates.get(&peer_name(peer)).filter(|h| !h.is_empty()) { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " throughput ".into()), + ( + p.dim.as_str(), + format!("(last {} samples)", hist.len().min(60)), + ), + ], + w - 1, + )); + let rx: Vec<f64> = hist.iter().map(|(r, _)| *r).collect(); + let tx: Vec<f64> = hist.iter().map(|(_, t)| *t).collect(); + let peak = rx + .iter() + .chain(tx.iter()) + .cloned() + .fold(0.0f64, f64::max) + .max(1.0); + let sw = w.saturating_sub(22).max(10); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " down ".into()), + (p.online.as_str(), spark(&rx, sw, peak)), + (p.dim.as_str(), format!(" {}", rate(*rx.last().unwrap_or(&0.0)))), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " up ".into()), + (p.relay.as_str(), spark(&tx, sw, peak)), + (p.dim.as_str(), format!(" {}", rate(*tx.last().unwrap_or(&0.0)))), + ], + w - 1, + )); + rows.push(tc::seg( + &[(p.dim.as_str(), format!(" peak {}", rate(peak)))], + w - 1, + )); + } + + if peer["ExitNodeOption"].as_bool().unwrap_or(false) { + rows.push(String::new()); + rows.push(tc::seg( + &[(p.exit.as_str(), " offers itself as an exit node".into())], + w - 1, + )); + } + + while rows.len() < h.saturating_sub(1) { + rows.push(String::new()); + } + rows.push(tc::seg( + &[(p.dim.as_str(), " [c]opy addresses · ← or esc to close · [q]uit".into())], + w - 1, + )); + rows +} + +fn copy_overlay( + peer: &serde_json::Value, + eps: &HashMap<String, Vec<String>>, + w: usize, + h: usize, + note: &str, + p: &Palette, +) -> Vec<String> { + let pairs = addresses(peer, eps); + let mut rows = vec![tc::title("copy address", w, &p.accent)]; + rows.push(tc::seg( + &[ + (p.accent.as_str(), format!(" {}", peer_name(peer))), + ( + p.dim.as_str(), + format!(" {}", text(peer, "DNSName").trim_end_matches('.')), + ), + ], + w - 1, + )); + rows.push(String::new()); + if pairs.is_empty() { + rows.push(tc::seg( + &[(p.dim.as_str(), " no addresses known for this machine".into())], + w - 1, + )); + } + for (i, (label, value)) in pairs.iter().enumerate() { + rows.push(tc::seg( + &[ + (p.online.as_str(), format!(" [{}] ", i + 1)), + (p.txt.as_str(), format!("{:<18} ", label)), + (p.accent.as_str(), value.clone()), + ], + w - 1, + )); + } + while rows.len() < h.saturating_sub(2) { + rows.push(String::new()); + } + rows.push(tc::seg( + &[( + p.dim.as_str(), + format!( + " press 1-{} to copy · ← or esc to close · [q]uit", + pairs.len().max(1) + ), + )], + w - 1, + )); + rows.push(if note.is_empty() { + String::new() + } else { + tc::seg(&[(p.online.as_str(), format!(" {}", note))], w - 1) + }); + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_name_comes_from_magicdns_not_the_device() { + // Two iPads both call themselves "localhost"; the MagicDNS label is + // unique across the tailnet and matches the admin console. + let peer: serde_json::Value = + serde_json::from_str(r#"{"DNSName": "garden-sensor.example.ts.net.", "HostName": "localhost"}"#) + .unwrap(); + assert_eq!(peer_name(&peer), "garden-sensor"); + // Only when there is no DNS name does the device get to say. + let bare: serde_json::Value = serde_json::from_str(r#"{"HostName": "kitchen-pi"}"#).unwrap(); + assert_eq!(peer_name(&bare), "kitchen-pi"); + assert_eq!(peer_name(&serde_json::json!({})), "?"); + } + + #[test] + fn an_address_is_classified_from_itself() { + // 100.64/10 is the CGNAT range Tailscale uses, not a public address. + assert_eq!(classify("100.64.0.1"), "tailscale"); + assert_eq!(classify("100.127.255.254"), "tailscale"); + assert_eq!(classify("100.63.0.1"), "public"); + assert_eq!(classify("100.128.0.1"), "public"); + assert_eq!(classify("fd7a:115c:a1e0::1"), "tailscale"); + assert_eq!(classify("10.0.0.5"), "private"); + assert_eq!(classify("172.17.0.1"), "private"); + assert_eq!(classify("192.168.1.4"), "private"); + assert_eq!(classify("203.0.113.9"), "public"); + assert_eq!(classify("169.254.1.1"), "other"); + assert_eq!(classify("127.0.0.1"), "other"); + // A zone index does not change what an address is. + assert_eq!(classify("fe80::1%eth0"), "other"); + } + + #[test] + fn a_cidr_contains_what_it_should() { + assert!(in_network("192.168.1.50", "192.168.1.0/24")); + assert!(!in_network("192.168.2.50", "192.168.1.0/24")); + assert!(in_network("10.4.5.6", "10.0.0.0/8")); + // A v6 route cannot contain a v4 address, and a bare address is not + // a network. + assert!(!in_network("10.4.5.6", "fd7a::/48")); + assert!(!in_network("10.4.5.6", "10.0.0.0")); + } + + #[test] + fn a_real_lan_address_beats_a_docker_bridge() { + let routes = vec!["192.168.7.0/24".to_string()]; + // Inside an advertised subnet wins outright. + assert_eq!(lan_rank("192.168.7.20", &routes), 0); + // Then 192.168, then 10, and docker's 172.17 last. + assert!(lan_rank("192.168.9.1", &routes) < lan_rank("10.1.2.3", &routes)); + assert!(lan_rank("10.1.2.3", &routes) < lan_rank("172.17.0.1", &routes)); + } + + #[test] + fn an_exit_node_route_does_not_defeat_the_ranking() { + // AllowedIPs carries 0.0.0.0/0 for an exit node, which would match + // every address; PrimaryRoutes is filtered so it cannot. + let peer: serde_json::Value = + serde_json::from_str(r#"{"PrimaryRoutes": ["0.0.0.0/0", "::/0", "192.168.7.0/24"]}"#) + .unwrap(); + assert_eq!(primary_routes(&peer), vec!["192.168.7.0/24"]); + } + + #[test] + fn a_byte_count_is_always_five_cells() { + // Up to petabytes, which is well past anything a WireGuard counter + // reaches before tailscaled restarts. Beyond that the number itself + // is wider than the column, and so is the Python's. + for n in [0u64, 9, 10, 1023, 1024, 15_000, 3_000_000_000, 900_000_000_000_000] { + assert_eq!(human(n).chars().count(), 5, "{}", n); + } + assert_eq!(human(0), " 0B"); + assert_eq!(human(1536), " 1.5K"); + assert_eq!(human(15_360), " 15K"); + } +} diff --git a/widgets/src/bin/tailnet_help.txt b/widgets/src/bin/tailnet_help.txt new file mode 100644 index 0000000..a041849 --- /dev/null +++ b/widgets/src/bin/tailnet_help.txt @@ -0,0 +1,41 @@ +Tailscale network: who is online, and how you are reaching them. + +RX and TX are traffic between *this* host and that peer, counted by the local +WireGuard engine — not the peer's own totals. They reset when tailscaled +restarts, so they cover that window rather than all time. + +The column that matters is PATH. A peer is either DIRECT, meaning NAT traversal +succeeded and traffic goes peer-to-peer, or it is relayed through a named DERP +region, meaning every packet round-trips through Tailscale's infrastructure. +Relayed peers can be dramatically slower and the difference is invisible in +`tailscale status` output unless you look for it. + +Peers advertising subnet routes are flagged, since those only reach you if this +node runs with --accept-routes. + +The info view names each peer's home DERP region — the Tailscale POP nearest to +it — as a location hint. That comes from the local DERP map, so no address is +ever sent to a geolocation service. + + tailnet [-n SECONDS] + +A live throughput section graphs peers currently moving data (toggle with g), +and the info view carries the same graph for the selected machine plus ICMP +latency over the tunnel — current, average, median, min, max, jitter, loss and +a sparkline — measured the same way the latency monitor does. Only the selected +peer is probed, so this costs one ping process regardless of tailnet size. + +i cycles the poll interval while running (1/2/5/10/30s), the same key the +latency monitor uses for it; the graph resolution follows it. -n sets the +starting value, and `tailnet.refresh` in config.json sets the default. + +Keys: up/down select a peer, right or Enter opens a full machine info view and left +comes back out (every address, +routes, tags, owner, handshake times), c or Enter opens a copy sheet offering its +Tailscale IP, MagicDNS name, public IP and LAN IP, r refreshes now, o hides +offline peers, q quits. Copying uses OSC 52, so it reaches the clipboard of +the machine you are typing at even over SSH. + +Needs the `tailscale` CLI. Peer LAN addresses come from `tailscale debug +netmap`, which needs root; it is attempted with `sudo -n` and simply omitted +when that would prompt, so nothing here requires privilege. diff --git a/widgets/src/bin/usage.rs b/widgets/src/bin/usage.rs new file mode 100644 index 0000000..f9c3b53 --- /dev/null +++ b/widgets/src/bin/usage.rs @@ -0,0 +1,1933 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! How much the coding agents on this machine have been used. +//! +//! A port of usage.py. One tab per agent, because they do not agree on what +//! usage even means: one counts tokens, another counts lines it wrote, and +//! several publish nothing at all outside their own session. An agent that +//! exposes nothing says so rather than showing a plausible zero. + +use std::collections::HashMap; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::{Datelike, Duration as Days, NaiveDate, TimeZone, Utc}; +use toys_core as tc; + +/// The priced kinds, in the order every rate card lists them. +const RATE_KINDS: &[&str] = &[ + "input", + "output", + "cache_read", + "cache_write", + "cache_write_1h", +]; + +const LIST_RATES_AS_OF: &str = "Aug 2026"; +/// Models known to have no published price: prefix matching would otherwise +/// hand gpt-5.3-codex-spark its family's rate, and Spark is explicitly not +/// on the API. Naming them makes them report as unpriced rather than as a +/// number nobody published. +const NO_PUBLISHED_PRICE: &[&str] = &["gpt-5.3-codex-spark", "codex-auto-review"]; + +/// Published list prices, US$ per million tokens, from the vendors' own +/// pricing pages on the date above. They are shipped because they are +/// published facts with a citable source, not a guess - but they go stale +/// silently, so the date is carried onto the screen with them and config +/// overrides any line. +/// +/// Cache writes come in two durations at different prices, and the +/// transcripts record which was taken per iteration, so both are carried +/// and neither is assumed. OpenAI does not charge for cache writes, so +/// those entries are absent rather than zero. +const LIST_RATES: &[(&str, &[(&str, f64)])] = &[ + ("gpt-5.6-sol", &[("input", 5.0), ("output", 30.0), ("cache_read", 0.50)]), + ("gpt-5.6-terra", &[("input", 2.0), ("output", 12.0), ("cache_read", 0.20)]), + ("gpt-5.6-luna", &[("input", 0.20), ("output", 1.20), ("cache_read", 0.02)]), + ("gpt-5.5-pro", &[("input", 30.0), ("output", 180.0)]), + ("gpt-5.5", &[("input", 5.0), ("output", 30.0), ("cache_read", 0.50)]), + ("gpt-5.4-mini", &[("input", 0.75), ("output", 4.50), ("cache_read", 0.075)]), + ("gpt-5.4-nano", &[("input", 0.20), ("output", 1.25), ("cache_read", 0.02)]), + ("gpt-5.4-pro", &[("input", 30.0), ("output", 180.0)]), + ("gpt-5.4", &[("input", 2.50), ("output", 15.0), ("cache_read", 0.25)]), + ("gpt-5.3-codex", &[("input", 1.75), ("output", 14.0), ("cache_read", 0.175)]), + ("gpt-5.2-pro", &[("input", 21.0), ("output", 168.0)]), + ("gpt-5.2", &[("input", 1.75), ("output", 14.0), ("cache_read", 0.175)]), + ("gpt-5.1", &[("input", 1.25), ("output", 10.0), ("cache_read", 0.125)]), + ("gpt-5-mini", &[("input", 0.25), ("output", 2.0), ("cache_read", 0.025)]), + ("gpt-5-nano", &[("input", 0.05), ("output", 0.40), ("cache_read", 0.005)]), + ("gpt-5-pro", &[("input", 15.0), ("output", 120.0)]), + ("gpt-5", &[("input", 1.25), ("output", 10.0), ("cache_read", 0.125)]), + ( + "claude-fable-5", + &[("input", 10.0), ("output", 50.0), ("cache_write", 12.50), ("cache_read", 1.0), ("cache_write_1h", 20.0)], + ), + ( + "claude-mythos-5", + &[("input", 10.0), ("output", 50.0), ("cache_write", 12.50), ("cache_read", 1.0), ("cache_write_1h", 20.0)], + ), + ( + "claude-opus-5", + &[("input", 5.0), ("output", 25.0), ("cache_write", 6.25), ("cache_read", 0.50), ("cache_write_1h", 10.0)], + ), + ( + "claude-opus-4-8", + &[("input", 5.0), ("output", 25.0), ("cache_write", 6.25), ("cache_read", 0.50), ("cache_write_1h", 10.0)], + ), + ( + "claude-opus-4-7", + &[("input", 5.0), ("output", 25.0), ("cache_write", 6.25), ("cache_read", 0.50), ("cache_write_1h", 10.0)], + ), + ( + "claude-opus-4-6", + &[("input", 5.0), ("output", 25.0), ("cache_write", 6.25), ("cache_read", 0.50), ("cache_write_1h", 10.0)], + ), + ( + "claude-opus-4-5", + &[("input", 5.0), ("output", 25.0), ("cache_write", 6.25), ("cache_read", 0.50), ("cache_write_1h", 10.0)], + ), + ( + "claude-opus-4-1", + &[("input", 15.0), ("output", 75.0), ("cache_write", 18.75), ("cache_read", 1.50), ("cache_write_1h", 30.0)], + ), + ( + "claude-sonnet-5", + &[("input", 2.0), ("output", 10.0), ("cache_write", 2.50), ("cache_read", 0.20), ("cache_write_1h", 4.0)], + ), + ( + "claude-sonnet-4-6", + &[("input", 3.0), ("output", 15.0), ("cache_write", 3.75), ("cache_read", 0.30), ("cache_write_1h", 6.0)], + ), + ( + "claude-sonnet-4-5", + &[("input", 3.0), ("output", 15.0), ("cache_write", 3.75), ("cache_read", 0.30), ("cache_write_1h", 6.0)], + ), + ( + "claude-haiku-4-5", + &[("input", 1.0), ("output", 5.0), ("cache_write", 1.25), ("cache_read", 0.10), ("cache_write_1h", 2.0)], + ), + ( + "claude-haiku-3-5", + &[("input", 0.80), ("output", 4.0), ("cache_write", 1.0), ("cache_read", 0.08), ("cache_write_1h", 1.6)], + ), +]; + +/// One hue, four steps, the way /stats and the contribution calendar do it. +/// heat() runs green to amber to red, which reads as a change of *kind* +/// rather than of amount - wrong for "more of the same thing". +const HEAT_STEPS: [(u8, u8, u8); 4] = [(74, 52, 46), (140, 78, 58), (196, 100, 66), (240, 132, 84)]; +// Carried with Claude's because they are the Python's own four-step ramps +// and the vendors that use them are the next thing to be ported. Kept here +// rather than reinvented later, where the numbers would drift. +#[allow(dead_code)] +const CODEX_STEPS: [(u8, u8, u8); 4] = + [(66, 72, 82), (122, 130, 144), (182, 190, 202), (240, 244, 250)]; +#[allow(dead_code)] +const GROK_STEPS: [(u8, u8, u8); 4] = [(44, 62, 88), (62, 104, 156), (86, 150, 210), (120, 196, 250)]; +#[allow(dead_code)] +const CURSOR_STEPS: [(u8, u8, u8); 4] = + [(48, 74, 66), (72, 124, 104), (100, 172, 142), (140, 220, 184)]; + +/// One hue per provider. Each is the colour that agent's own tab already +/// uses, so the same agent looks the same wherever you meet it. Copilot and +/// Antigravity have no calendar to borrow from and get their own, chosen to +/// sit clear of the amber and red this widget reserves for trouble. +fn agent_hue(name: &str) -> Option<(u8, u8, u8)> { + Some(match name { + "claude" => (240, 132, 84), + "codex" => (206, 214, 228), + "cursor" => (126, 208, 176), + "grok" => (120, 196, 250), + "copilot" => (186, 166, 255), + "antigravity" => (232, 158, 200), + _ => return None, + }) +} + +#[allow(dead_code)] +fn agent_steps(name: &str) -> [(u8, u8, u8); 4] { + match name { + "codex" => CODEX_STEPS, + "grok" => GROK_STEPS, + "cursor" => CURSOR_STEPS, + _ => HEAT_STEPS, + } +} + +const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const SUMMARY_TAB: &str = "+"; +const ORDER: &[&str] = &["claude", "codex", "cursor", "grok", "copilot", "antigravity"]; +const MONTHS: &[&str] = &[ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/// Below this much of a window, a pace figure is noise. +const PACE_FLOOR: f64 = 3.0; +/// The five-hour session and the seven-day total, which the response names +/// in its own top-level keys rather than in limits[]. +const CLAUDE_WINDOW_SECS: &[(&str, f64)] = &[("session", 5.0 * 3600.0), ("weekly", 7.0 * 86400.0)]; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +fn home() -> String { + std::env::var("HOME").unwrap_or_default() +} + +fn under_home(rest: &str) -> String { + format!("{}/{}", home(), rest) +} + +fn text(value: &serde_json::Value, key: &str) -> String { + value[key].as_str().unwrap_or("").to_string() +} + +fn num(value: &serde_json::Value, key: &str) -> f64 { + value[key].as_f64().unwrap_or(0.0) +} + +/// A duration in milliseconds as days, hours and minutes. +fn span_ms(ms: f64) -> String { + let s = (ms / 1000.0) as i64; + let (d, rest) = (s / 86400, s % 86400); + let (h, rest) = (rest / 3600, rest % 3600); + let m = rest / 60; + if d > 0 { + format!("{}d {}h {}m", d, h, m) + } else if h > 0 { + format!("{}h {}m", h, m) + } else if m > 0 { + // A couple of seconds of generation is not "0m". + format!("{}m", m) + } else { + format!("{:.1}s", ms / 1000.0) + } +} + +/// Token counts run to billions; nobody reads eleven digits. +fn big_num(n: f64) -> String { + for (unit, size) in [("B", 1e9), ("M", 1e6), ("k", 1e3)] { + if n.abs() >= size { + return format!("{:.1}{}", n / size, unit); + } + } + format!("{}", n as i64) +} + +fn ago(when: f64) -> String { + if when <= 0.0 { + return "never".into(); + } + let s = now() - when; + if s < 60.0 { + format!("{}s", s as i64) + } else if s < 3600.0 { + format!("{}m", (s / 60.0) as i64) + } else if s < 86400.0 { + format!("{}h", (s / 3600.0) as i64) + } else if s < 365.0 * 86400.0 { + format!("{}d", (s / 86400.0) as i64) + } else { + // A subscription can be years old, and "890d" is not a span anyone + // reads. + format!("{:.1}y", s / (365.0 * 86400.0)) + } +} + +/// ISO-8601 to epoch seconds. +/// +/// These APIs mix a trailing Z with +00:00 in the same response, and Go +/// writes nanoseconds where the parsers take three or six digits. +fn iso_epoch(s: &str) -> Option<f64> { + if s.is_empty() { + return None; + } + // Z and +00:00 are the same zone and these APIs mix them inside one + // response. Spelling it one way means every zoned stamp is parsed by + // the same branch - the two branches did not agree about whether a + // fraction of a second survives. + let s: String = match s.strip_suffix('Z') { + Some(head) => format!("{}+00:00", head), + None => s.to_string(), + }; + let s = s.as_str(); + // Trim any sub-second field to microseconds, whatever it arrived as. + let cleaned = match s.find('.') { + Some(dot) => { + let tail = &s[dot + 1..]; + let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect(); + let rest = &tail[digits.len()..]; + format!("{}.{}{}", &s[..dot], &digits[..digits.len().min(6)], rest) + } + None => s.to_string(), + }; + for fmt in [ + "%Y-%m-%dT%H:%M:%S%.f%:z", + "%Y-%m-%dT%H:%M:%S%:z", + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S", + ] { + if let Ok(at) = chrono::DateTime::parse_from_str(&cleaned, fmt) { + return Some(at.timestamp() as f64 + at.timestamp_subsec_micros() as f64 / 1e6); + } + if let Ok(at) = chrono::NaiveDateTime::parse_from_str(&cleaned, fmt) { + // Unzoned, so read as UTC. usage.py reads these as local, but + // its own docstring says the callers all pass zoned strings - + // and every caller here does. + let at = Utc.from_utc_datetime(&at); + return Some(at.timestamp() as f64 + at.timestamp_subsec_micros() as f64 / 1e6); + } + } + None +} + +/// A date, kept in the zone it arrived in. +/// +/// assigned_date carries the account's own offset. Converting it to this +/// machine's zone can move it a day - 3 Jun at 12:10 -07:00 is 4 Jun in UTC - +/// and then the pane disagrees with what the vendor shows for the same seat. +fn iso_day(s: &str) -> String { + let s = s.trim_end_matches('Z'); + for fmt in ["%Y-%m-%dT%H:%M:%S%.f%:z", "%Y-%m-%dT%H:%M:%S%:z"] { + if let Ok(at) = chrono::DateTime::parse_from_str(s, fmt) { + return format!("{} {} {}", at.day(), MONTHS[at.month0() as usize], at.year()); + } + } + for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"] { + if let Ok(at) = chrono::NaiveDateTime::parse_from_str(s, fmt) { + return format!("{} {} {}", at.day(), MONTHS[at.month0() as usize], at.year()); + } + if let Ok(at) = NaiveDate::parse_from_str(s, fmt) { + return format!("{} {} {}", at.day(), MONTHS[at.month0() as usize], at.year()); + } + } + String::new() +} + +fn left_span(secs: f64) -> String { + let s = secs as i64; + let (d, rest) = (s / 86400, s % 86400); + let (h, rest) = (rest / 3600, rest % 3600); + if d > 0 { + format!("{}d {}h", d, h) + } else if h > 0 { + format!("{}h {}m", h, rest / 60) + } else { + format!("{}m", rest / 60) + } +} + +/// How far ahead of the clock a quota is, as a signed percentage. +/// +/// The share of the window already gone minus the share of the allowance +/// already spent. Positive is headroom; negative means this runs out before +/// the window does. Below PACE_FLOOR of a window it is not shown at all, +/// because ten minutes into a week every number looks like a catastrophe or +/// a triumph. +fn lead(pct_used: f64, window_secs: Option<f64>, reset_ts: Option<f64>) -> Option<f64> { + let (window, reset) = (window_secs?, reset_ts?); + if window <= 0.0 { + return None; + } + let gone = window - (reset - now()); + if gone <= 0.0 || gone > window { + return None; + } + let elapsed = 100.0 * gone / window; + if elapsed < PACE_FLOOR { + return None; + } + Some(elapsed - pct_used) +} + +/// A percentage with enough precision to prove it is not a placeholder. +/// +/// Every Antigravity lane rounded to "0%" - which is what an empty section +/// looks like - while one was genuinely 0.4% spent and another 0.03%. A real +/// small number and no number at all have to be tellable apart. +fn pct_text(pct: f64) -> String { + if pct <= 0.0 { + " 0%".into() + } else if pct < 1.0 { + format!("{:5.2}%", pct) + } else if pct < 10.0 { + format!("{:5.1}%", pct) + } else { + format!("{:5.0}%", pct) + } +} + +/// How much of a window has gone, from its length and its reset. +fn elapsed_of(secs: Option<f64>, reset: Option<f64>) -> Option<f64> { + let (secs, reset) = (secs?, reset?); + if secs <= 0.0 { + return None; + } + let left = reset - now(); + if left <= 0.0 || left > secs { + return None; + } + Some((secs - left) / secs) +} + +/// The dark end of every agent ramp. The two stops above it are measured, +/// not picked: 0.51 keeps the dimmest filled cell at 3:1 against the +/// background for the darkest agent hue, and 0.34 leaves the empty track at +/// least as visible as the flat grid it replaces. +const BAR_FLOOR: (u8, u8, u8) = (30, 38, 52); + +fn blend(hue: (u8, u8, u8), t: f64) -> (u8, u8, u8) { + let step = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8; + ( + step(BAR_FLOOR.0, hue.0), + step(BAR_FLOOR.1, hue.1), + step(BAR_FLOOR.2, hue.2), + ) +} + +/// A tint of an agent's colour, t running dark to full. +/// +/// Not `shade` - that name is taken by the calendars' four-step ramp, and +/// two functions of the same name meant the heatmaps drew with this one. +fn tint(hue: (u8, u8, u8), t: f64) -> String { + let (r, g, b) = blend(hue, t); + tc::rgb(r, g, b) +} + +/// Which of the four steps a day falls in. +fn shade(frac: f64, steps: [(u8, u8, u8); 4]) -> String { + let at = ((frac * 3.999) as usize).min(3); + let (r, g, b) = steps[at]; + tc::rgb(r, g, b) +} + +struct Palette { + ok: String, + warn: String, + bad: String, + dim: String, + grid: String, + txt: String, + lbl: String, + accent: String, + agent: String, + empty_cell: String, + /// The notch is white on its own dark cell rather than a bare + /// foreground colour: plain white manages 1.2:1 against a full bar, so + /// it disappears exactly where it matters. + pace_mark: String, + /// Default background again, and only that. + nobg: String, +} + +fn palette() -> Palette { + Palette { + ok: tc::rgb(90, 240, 160), + warn: tc::rgb(255, 200, 90), + bad: tc::rgb(255, 100, 110), + dim: tc::rgb(127, 147, 172), + grid: tc::rgb(60, 78, 98), + txt: tc::rgb(225, 235, 245), + lbl: tc::rgb(130, 165, 200), + accent: tc::rgb(150, 210, 255), + agent: tc::rgb(180, 160, 255), + empty_cell: tc::rgb(58, 66, 80), + pace_mark: format!("{}{}", tc::bg(10, 12, 18), tc::rgb(238, 244, 252)), + nobg: tc::NOBG.to_string(), + } +} + +/// What colour a quota's percentage is written in. +/// +/// The agent's own colour, so the number matches the bar it sits beside. +/// Red is the one exception, at 90% spent, because nearly empty is trouble +/// whatever the pace. Behind-the-clock deliberately does not colour this: +/// the pace cell beside it is already amber for exactly that, and a number +/// and its own explanation both turning yellow reads as two problems. +fn pct_colour(pct: f64, hue: Option<(u8, u8, u8)>, p: &Palette) -> String { + if pct >= 90.0 { + return p.bad.clone(); + } + match hue { + Some((r, g, b)) => tc::rgb(r, g, b), + None => tc::heat(pct / 100.0), + } +} + +/// The signed cushion, coloured by whether it is one. +fn pace_cell(value: Option<f64>, p: &Palette) -> (String, String) { + pace_cell_of(value, false, p) +} + +/// The pace figure, with `~` when it rests on a cached percentage rather +/// than one fetched just now. +/// +/// The tilde is doing real work here and it is worth being clear what it +/// covers. Where the cached window is still open the figure is a few +/// minutes stale and the mark is honest. Where that window has closed, the +/// percentage is the *previous* window's final one and the counter has since +/// reset - so the figure is carried forward rather than extrapolated, and +/// the `~` is the only thing saying so. The star beside the bar says the +/// reading is cached; the agent's own tab says how old. +fn pace_cell_of(value: Option<f64>, guessed: bool, p: &Palette) -> (String, String) { + match value { + None => (p.dim.clone(), String::new()), + Some(v) => ( + if guessed { + p.dim.clone() + } else if v >= 0.0 { + p.ok.clone() + } else { + p.warn.clone() + }, + format!(" {}{:+.0}%", if guessed { "~" } else { "" }, v), + ), + } +} + +/// A quota bar with a mark where an even burn would have reached by now. +/// +/// The percentage alone cannot separate a lane 71% spent with three weeks +/// left from one 71% spent with three days left, and colour alone cannot +/// either - both are the same red. The mark is the window's own progress, +/// so a fill short of it is spending slower than the clock. +fn paced_bar( + used: f64, + elapsed: Option<f64>, + room: usize, + hue: Option<(u8, u8, u8)>, + p: &Palette, +) -> Vec<(String, String)> { + let bar = tc::meter(used, room); + let filled = bar.chars().filter(|c| *c == '█').count(); + let at = elapsed.map(|e| ((e * room as f64).round() as usize).min(room.saturating_sub(1))); + let mut parts: Vec<(String, String)> = Vec::new(); + for (i, ch) in bar.chars().enumerate() { + let (colour, glyph) = if Some(i) == at { + // One colour for the mark on every bar. It is a reference line - + // where an even burn would have reached - and a line that + // changes colour looks like it has a state of its own, when the + // state being reported is the fill's position relative to it. + (p.pace_mark.clone(), '┃') + } else if i < filled { + // Filled cells run dark to full across the fill, so the bar is + // recognisably its agent's colour and still reads as a quantity + // without counting cells. + let t = 0.51 + 0.49 * (i as f64 / filled.saturating_sub(1).max(1) as f64); + ( + format!( + "{}{}", + p.nobg, + match hue { + Some(hue) => tint(hue, t), + None => tc::heat(used), + } + ), + ch, + ) + } else { + ( + format!( + "{}{}", + p.nobg, + match hue { + Some(hue) => tint(hue, 0.34), + None => p.grid.clone(), + } + ), + ch, + ) + }; + match parts.last_mut() { + Some((had, run)) if *had == colour => run.push(glyph), + _ => parts.push((colour, glyph.to_string())), + } + } + // The mark is the one thing here that sets a background, so the run ends + // by putting it back. Without this the dark cell bled through everything + // drawn after it on the row. + parts.push((p.nobg.clone(), String::new())); + parts +} + +/// Plain text flowed to a width. Clipping a sentence loses its end. +fn wrap_text(t: &str, budget: usize) -> Vec<String> { + let mut lines = Vec::new(); + let mut line = String::new(); + for word in t.split_whitespace() { + if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > budget { + lines.push(std::mem::take(&mut line)); + line.push_str(word); + } else if line.is_empty() { + line.push_str(word); + } else { + line.push(' '); + line.push_str(word); + } + } + if !line.is_empty() { + lines.push(line); + } + if lines.is_empty() { + vec![String::new()] + } else { + lines + } +} + +/// A labelled value flowed onto as many lines as it needs. +/// +/// Only text can be wrapped. A bar chart broken across two lines is not a +/// bar chart, which is why those adapt to the width instead. The +/// continuation lines sit under the value rather than under the label. +fn wrap_pair(key: &str, value: &str, label_w: usize, w: usize) -> Vec<(String, String)> { + let budget = (w.saturating_sub(label_w + 5)).max(8); + let mut lines: Vec<String> = Vec::new(); + let mut line = String::new(); + for word in value.split_whitespace() { + let mut word = word.to_string(); + if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > budget { + lines.push(std::mem::take(&mut line)); + } + // A single word longer than the column is split rather than allowed + // to run off; an enterprise sku is one word and still has to fit. + while word.chars().count() > budget { + lines.push(word.chars().take(budget).collect()); + word = word.chars().skip(budget).collect(); + } + if line.is_empty() { + line = word; + } else { + line.push(' '); + line.push_str(&word); + } + } + if !line.is_empty() { + lines.push(line); + } + lines + .into_iter() + .enumerate() + .map(|(i, part)| (if i == 0 { key.to_string() } else { String::new() }, part)) + .collect() +} + +/// What to run to make each agent start recording. An empty tab that only +/// says "nothing here" leaves the reader to guess whether it is broken. +fn run_hint(name: &str) -> &'static str { + match name { + "claude" => "claude", + "codex" => "codex", + "cursor" => "cursor-agent", + "grok" => "grok", + "copilot" => "copilot", + _ => "", + } +} + +/// The empty state: what is missing, and the one command that fixes it. +fn no_local(what: &str, run: &str, w: usize, p: &Palette) -> Vec<String> { + let mut rows: Vec<String> = wrap_text(what, w.saturating_sub(4).max(20)) + .into_iter() + .map(|line| tc::seg(&[(p.dim.as_str(), format!(" {}", line))], w - 1)) + .collect(); + if !run.is_empty() { + rows.push(tc::seg( + &[ + (p.dim.as_str(), " run ".into()), + (p.accent.as_str(), run.to_string()), + (p.dim.as_str(), " here and this fills in".into()), + ], + w - 1, + )); + } + rows +} + +/// A rate card: US$ per million tokens, by priced kind. +type Rate = HashMap<String, f64>; + +/// The rate for a model, and where it came from. +/// +/// Config wins outright, then the published list prices. Keyed by model +/// rather than by agent, because a model has one list price wherever it +/// ran. Longest matching name wins, so claude-opus-4 does not shadow +/// claude-opus-4-8, and a "*" entry catches anything left over. +fn rate_for(model: &str, configured: &HashMap<String, Rate>) -> (Option<Rate>, &'static str) { + if NO_PUBLISHED_PRICE.contains(&model) && !configured.contains_key(model) { + return (None, ""); + } + if let Some(rate) = configured.get(model) { + return (Some(rate.clone()), "config"); + } + let mut best: Option<(usize, Rate)> = None; + for (key, rate) in configured { + if key != "*" && model.contains(key.as_str()) { + let len = key.chars().count(); + if best.as_ref().is_none_or(|(had, _)| len > *had) { + best = Some((len, rate.clone())); + } + } + } + if let Some((_, rate)) = best { + return (Some(rate), "config"); + } + if let Some(rate) = configured.get("*") { + return (Some(rate.clone()), "config"); + } + let to_rate = |entries: &[(&str, f64)]| -> Rate { + entries.iter().map(|(k, v)| (k.to_string(), *v)).collect() + }; + for (key, entries) in LIST_RATES { + if *key == model { + return (Some(to_rate(entries)), "list"); + } + } + let mut best: Option<(usize, Rate)> = None; + for (key, entries) in LIST_RATES { + if model.contains(key) { + let len = key.chars().count(); + if best.as_ref().is_none_or(|(had, _)| len > *had) { + best = Some((len, to_rate(entries))); + } + } + } + match best { + Some((_, rate)) => (Some(rate), "list"), + None => (None, ""), + } +} + +/// Token counts by priced kind. +type Tokens = HashMap<String, f64>; + +fn cost_of(tokens: &Tokens, rate: &Rate) -> f64 { + RATE_KINDS + .iter() + .map(|kind| { + tokens.get(*kind).copied().unwrap_or(0.0) / 1e6 + * rate.get(*kind).copied().unwrap_or(0.0) + }) + .sum() +} + +fn empty_tokens() -> Tokens { + RATE_KINDS.iter().map(|k| (k.to_string(), 0.0)).collect() +} + +fn total_tokens(t: &Tokens) -> f64 { + RATE_KINDS + .iter() + .map(|k| t.get(*k).copied().unwrap_or(0.0)) + .sum() +} + +/// One costed window: its label, what it cost, how many tokens, and the +/// models under it. +type Window = (String, f64, f64, Vec<(String, f64)>); + +/// The metered section: one row per window, each with its models under it. +/// +/// Two windows - today and thirty days - because a month's total says what +/// an agent costs and today says whether that is still true. A single +/// all-time figure answered neither question. +#[allow(clippy::too_many_arguments)] +fn metered_block( + where_: &str, + windows: &[Window], + w: usize, + extras: &[(String, Option<f64>, String)], + note: &str, + scope: &str, + caveat: &str, + p: &Palette, +) -> Vec<String> { + // Windows are kept even when empty, as long as something is. A zero + // today against a busy month is the answer to "have I used this today". + if !windows.iter().any(|x| x.1 > 0.0 || x.2 > 0.0) { + return Vec::new(); + } + // Scope first, because it is the thing most easily got wrong: this + // section sits under a QUOTA labelled "account-wide", and a local figure + // beside it reads as the same scope unless it says otherwise. + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── METERED ── ".into()), + ( + p.txt.as_str(), + if scope.is_empty() { String::new() } else { format!("{} · ", scope) }, + ), + (p.dim.as_str(), format!("at {}", where_)), + ( + p.dim.as_str(), + if note.is_empty() { String::new() } else { format!(" {}", note) }, + ), + ], + w - 1, + )]; + if !caveat.is_empty() { + for line in wrap_text(caveat, w.saturating_sub(4).max(20)) { + rows.push(tc::seg(&[(p.dim.as_str(), format!(" {}", line))], w - 1)); + } + } + let extras: Vec<&(String, Option<f64>, String)> = + extras.iter().filter(|x| x.1.is_some()).collect(); + let label_w = windows + .iter() + .map(|x| x.0.chars().count()) + .chain(extras.iter().map(|x| x.0.chars().count())) + .max() + .unwrap_or(6); + for (label, cost, tokens, models) in windows { + rows.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {} ", tc::pad(label, label_w))), + (p.agent.as_str(), tc::pad(&format!("${:.2}", cost), 11)), + (p.dim.as_str(), format!("{} tokens", big_num(*tokens))), + ], + w - 1, + )); + let top: Vec<&(String, f64)> = models.iter().take(5).collect(); + let name_w = top.iter().map(|(m, _)| m.chars().count()).max().unwrap_or(0); + for (model, model_cost) in &top { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ", " ".repeat(label_w))), + (p.dim.as_str(), format!("{} ", tc::pad(model, name_w))), + (p.txt.as_str(), format!("${:.2}", model_cost)), + ], + w - 1, + )); + } + if models.len() > top.len() { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ", " ".repeat(label_w))), + (p.dim.as_str(), format!("+{} more", models.len() - top.len())), + ], + w - 1, + )); + } + } + // Summary rows below the windows rather than in the header, which had + // grown long enough to clip the moment a scope word joined it. + for (label, value, colour) in extras { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ", tc::pad(label, label_w))), + (colour.as_str(), format!("${:.2}", value.unwrap_or(0.0))), + ], + w - 1, + )); + } + rows.push(String::new()); + rows +} + +/// Cost a set of windows against the rate card. +/// +/// Only models with a rate are counted and the unpriced ones are named, so +/// a half-filled card cannot read as a total. +#[allow(clippy::too_many_arguments)] +fn metered_rows( + windows: &[(String, Vec<(String, Tokens)>)], + w: usize, + note: &str, + agent: &str, + scope: &str, + caveat: &str, + cfg: &Config, + p: &Palette, +) -> Vec<String> { + let mut origins: Vec<&'static str> = Vec::new(); + let mut missing: Vec<String> = Vec::new(); + let mut built: Vec<Window> = Vec::new(); + for (label, entries) in windows { + let (mut cost, mut tokens) = (0.0, 0.0); + let mut models: Vec<(String, f64)> = Vec::new(); + for (model, counts) in entries { + if total_tokens(counts) <= 0.0 { + continue; + } + let (rate, origin) = rate_for(model, &cfg.rates); + tokens += total_tokens(counts); + let Some(rate) = rate else { + if !missing.contains(model) { + missing.push(model.clone()); + } + continue; + }; + let this = cost_of(counts, &rate); + cost += this; + if !origins.contains(&origin) { + origins.push(origin); + } + models.push((model.clone(), this)); + } + models.sort_by(|a, b| b.1.total_cmp(&a.1)); + built.push((label.clone(), cost, tokens, models)); + } + if !built.iter().any(|x| x.1 > 0.0) { + if cfg.rates.is_empty() { + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── METERED ── ".into()), + (p.dim.as_str(), "no published rates for these models".into()), + ], + w - 1, + )]; + rows.extend(no_local( + "Set usage.rates in config.json - US$ per million tokens, keyed by model.", + "", + w, + p, + )); + rows.push(String::new()); + return rows; + } + return Vec::new(); + } + // Where the prices came from belongs on screen: a list price is a dated + // fact that goes stale in silence, and a configured one is the reader's + // own assertion. Neither should be mistaken for the other. + let where_ = if origins == ["config"] { + "your configured rates".to_string() + } else if origins == ["list"] { + format!("list prices · {}", LIST_RATES_AS_OF) + } else { + format!("list prices · {}, some configured", LIST_RATES_AS_OF) + }; + // A month's list cost against what the month actually cost you. Shown + // only when the plan price is configured, because it is the one figure + // in this section that no machine here knows. + let month = built.iter().find(|x| x.0 == "30 days").map(|x| x.1); + let saves = match (cfg.plan_cost.get(agent), month) { + (Some(paid), Some(month)) => Some(month - paid), + _ => None, + }; + let mut rows = metered_block( + &where_, + &built, + w, + &[("the plan saves".to_string(), saves, p.ok.clone())], + note, + scope, + caveat, + p, + ); + if !missing.is_empty() && !rows.is_empty() { + missing.sort(); + let at = rows.len() - 1; + rows.insert( + at, + tc::seg( + &[ + ( + p.warn.as_str(), + format!( + " {} model{} unpriced: ", + missing.len(), + if missing.len() == 1 { "" } else { "s" } + ), + ), + ( + p.dim.as_str(), + missing.iter().take(3).cloned().collect::<Vec<_>>().join(", "), + ), + ], + w - 1, + ), + ); + } + rows +} + +/// A subscription block: what the plan is, then the facts about it. +/// +/// Shared by four tabs so the same question is answered in the same shape +/// wherever you are on the wall - a percentage means little without the +/// subscription it is a percentage of. +fn plan_rows( + headline: &str, + pairs: &[(String, String)], + w: usize, + note: &str, + wrapped: Option<(&str, &[String])>, + caveat: &str, + p: &Palette, +) -> Vec<String> { + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── SUBSCRIPTION ── ".into()), + ( + p.txt.as_str(), + if headline.is_empty() { "unknown".into() } else { headline.to_string() }, + ), + ( + p.dim.as_str(), + if note.is_empty() { String::new() } else { format!(" {}", note) }, + ), + ], + w - 1, + )]; + if !caveat.is_empty() { + for line in wrap_text(caveat, w.saturating_sub(4).max(20)) { + rows.push(tc::seg(&[(p.dim.as_str(), format!(" {}", line))], w - 1)); + } + } + let label_w = pairs + .iter() + .map(|(k, _)| k.chars().count()) + .chain(wrapped.iter().map(|(k, _)| k.chars().count())) + .max() + .unwrap_or(0); + for (key, value) in pairs { + for (lab, part) in wrap_pair(key, value, label_w, w) { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ", tc::pad(&lab, label_w))), + (p.txt.as_str(), part), + ], + w - 1, + )); + } + } + if let Some((label, names)) = wrapped { + if !names.is_empty() { + // Wrapped rather than clipped: a truncated list reads as a + // shorter one, and only the first line takes the label. + let budget = w.saturating_sub(label_w + 6).max(10); + let mut lines: Vec<Vec<String>> = Vec::new(); + let mut line: Vec<String> = Vec::new(); + for name in names { + let mut trial = line.clone(); + trial.push(name.clone()); + if !line.is_empty() && trial.join(" · ").chars().count() > budget { + lines.push(std::mem::take(&mut line)); + } + line.push(name.clone()); + } + if !line.is_empty() { + lines.push(line); + } + for (i, part) in lines.iter().enumerate() { + rows.push(tc::seg( + &[ + ( + p.dim.as_str(), + format!(" {} ", tc::pad(if i == 0 { label } else { "" }, label_w)), + ), + (p.ok.as_str(), part.join(" · ")), + ], + w - 1, + )); + } + } + } + rows +} + +/// Append a section with exactly one blank line before it. +/// +/// The separator is owned here rather than by callers who each end +/// differently - some finish on a blank line and would otherwise leave two, +/// and plain concatenation leaves none at all. +fn add_section(mut rows: Vec<String>, block: Vec<String>) -> Vec<String> { + if block.is_empty() { + return rows; + } + while rows.last().is_some_and(|x| x.is_empty()) { + rows.pop(); + } + rows.push(String::new()); + rows.extend(block); + rows +} + +/// Tokens per day, drawn the way Claude Code's own /stats draws it. +/// +/// Weekday rows with only Mon, Wed and Fri labelled; one cell per day; +/// months named across the top; solid blocks in four steps of a single hue, +/// and a dim dot for a day the file has no entry for. +struct Calendar { + rows: Vec<Vec<(String, String)>>, + best: Option<NaiveDate>, + active: usize, + span: usize, + longest: usize, + current: usize, +} + +fn day_calendar( + totals: &HashMap<NaiveDate, f64>, + w: usize, + steps: [(u8, u8, u8); 4], + weeks: Option<usize>, + p: &Palette, +) -> Option<Calendar> { + if totals.is_empty() { + return None; + } + let peak = totals.values().cloned().fold(0.0f64, f64::max).max(1.0); + let best = totals + .iter() + .max_by(|a, b| a.1.total_cmp(b.1)) + .map(|(d, _)| *d); + let last = *totals.keys().max()?; + let first = *totals.keys().min()?; + // A caller with a bounded window says so, rather than having its month + // of data stretched across a year of empty dots. + let fit = weeks.unwrap_or(w.saturating_sub(7)).clamp(4, w.saturating_sub(7).max(4)); + let end_week = last - Days::days(last.weekday().num_days_from_monday() as i64); + let starts: Vec<NaiveDate> = (0..fit) + .rev() + .map(|i| end_week - Days::days(7 * i as i64)) + .collect(); + + // Month names sit over the week their month starts in, three characters + // wide like /stats - a single initial is not a label, it is a hint. A + // month label needs three clear cells; without checking where the last + // one ended, a short month writes over its neighbour. + let mut strip = vec![' '; starts.len()]; + let (mut seen, mut wrote_to): (Option<u32>, i64) = (None, -1); + for (x, wk) in starts.iter().enumerate() { + if Some(wk.month()) != seen && x as i64 > wrote_to && x + 3 <= strip.len() { + seen = Some(wk.month()); + for (k, ch) in MONTHS[wk.month0() as usize].chars().enumerate() { + strip[x + k] = ch; + } + wrote_to = x as i64 + 3; + } + } + let mut rows: Vec<Vec<(String, String)>> = vec![vec![( + p.dim.clone(), + format!(" {}", strip.iter().collect::<String>()), + )]]; + for i in 0..7 { + let label = match i { + 0 => "Mon", + 2 => "Wed", + 4 => "Fri", + _ => "", + }; + let mut line = vec![(p.dim.clone(), format!(" {:<4}", label))]; + for wk in &starts { + let day = *wk + Days::days(i); + match totals.get(&day) { + None => line.push((p.empty_cell.clone(), "·".into())), + Some(n) => line.push((shade((n / peak).sqrt(), steps), "█".into())), + } + } + rows.push(line); + } + + // Active out of days in the range, not out of days the file happens to + // list - otherwise every day is active by construction. + let span = (last - first).num_days() as usize + 1; + let active = totals.values().filter(|v| **v > 0.0).count(); + let (mut run, mut longest) = (0usize, 0usize); + for i in 0..span { + let day = first + Days::days(i as i64); + run = if totals.get(&day).is_some_and(|v| *v > 0.0) { run + 1 } else { 0 }; + longest = longest.max(run); + } + let mut current = 0usize; + for i in 0..span { + let day = last - Days::days(i as i64); + if !totals.get(&day).is_some_and(|v| *v > 0.0) { + break; + } + current += 1; + } + Some(Calendar { + rows, + best, + active, + span, + longest, + current, + }) +} + +/// Settings, read once, so no widget-wide mutable globals are needed. +#[derive(Default, Clone)] +struct Config { + agents: Vec<String>, + exclude_agents: Vec<String>, + rates: HashMap<String, Rate>, + plan_cost: HashMap<String, f64>, + refresh: f64, + /// Grok is the only agent with no live quota unless it is asked for one. + /// Off by default: asking means a request to x.ai carrying the token its + /// CLI left on disk, and a widget that reads should not start talking to + /// a vendor because it was launched. + /// + /// Turning it on also permits running the Grok CLI once after a session + /// goes quiet, because that is what refreshes the token the request + /// needs. The two were separate settings for one release and should not + /// have been: asking without refreshing works until the token lapses and + /// then stops, silently, which is the failure the refresh exists to + /// prevent. Nobody wants the first without the second. + grok_ping: bool, + /// Whether Antigravity's quota may be asked of Google when nothing is + /// serving it locally. + /// + /// On by default, unlike `grok_ping`, and the difference is what the + /// request is. Grok's asks a vendor for a reading nothing on this + /// machine has. This one asks for the same reading the app already + /// serves over localhost, from the same host and with the same + /// credential the tier is already fetched with. Turning it off costs + /// the quota whenever Antigravity is closed and spares nothing that the + /// tier request has not already spent. + antigravity_remote: bool, + /// Whether the widget may start the `agy` CLI to read the quota it + /// serves, when nothing else has one. + /// + /// On by default, and the only thing here that runs somebody else's + /// program. Three things make that defensible where Grok's equivalent + /// is off: it is the reader's own CLI, already installed and signed in; + /// it is started under a pseudo-terminal of ours, killed by pid the + /// moment the reading is taken, and reaped, so nothing outlives the + /// fetch; and a CLI the reader started themselves is found by the + /// ordinary local probe long before this runs, so this can never shut + /// one down. Turn it off and the quota lasts an hour past the last time + /// Antigravity ran, which is the token's life. + antigravity_start: bool, + /// Minutes between those requests. Five, so the figure on screen is + /// one a reader can act on: the window it reports moves over days, but + /// the spend inside it moves while they work, and an hour-old reading + /// of a live session is exactly the stale number this asks the server + /// to avoid. One small GET twelve times an hour is not traffic. + grok_ping_minutes: f64, +} + +fn read_config() -> Config { + let raw = tc::load_config("usage"); + let table = |key: &str| -> HashMap<String, Rate> { + raw[key] + .as_object() + .into_iter() + .flatten() + .map(|(model, entry)| { + let rate: Rate = entry + .as_object() + .into_iter() + .flatten() + .filter_map(|(k, v)| v.as_f64().map(|v| (k.clone(), v))) + .collect(); + (model.clone(), rate) + }) + .collect() + }; + Config { + agents: tc::cfg_strings(&raw, "agents", &[]), + exclude_agents: tc::cfg_strings(&raw, "exclude_agents", &[]), + rates: table("rates"), + plan_cost: raw["plan_cost"] + .as_object() + .into_iter() + .flatten() + .filter_map(|(k, v)| v.as_f64().map(|v| (k.clone(), v))) + .collect(), + refresh: tc::poll_secs(tc::cfg_f64(&raw, "refresh", 30.0), 30.0), + grok_ping: raw + .get("grok_ping") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + grok_ping_minutes: tc::cfg_f64(&raw, "grok_ping_minutes", 5.0), + antigravity_remote: raw + .get("antigravity_remote") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + antigravity_start: raw + .get("antigravity_start") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + } +} + +/// What we know how to read, and how to tell it is here. +/// +/// An agent counts as present if its CLI is on PATH *or* it has left state +/// behind: an uninstalled agent whose history is still on disk is worth +/// showing, and a CLI installed under a different name would otherwise +/// vanish. +fn agent_spec(name: &str) -> (&'static str, Vec<&'static str>, Vec<String>) { + match name { + "claude" => ( + "Claude Code", + vec!["claude"], + vec![under_home(".claude/stats-cache.json")], + ), + "codex" => ("OpenAI Codex", vec!["codex"], vec![under_home(".codex/sessions")]), + "cursor" => ( + "Cursor", + vec!["cursor-agent", "cursor"], + vec![under_home(".cursor/ai-tracking/ai-code-tracking.db")], + ), + "grok" => ("Grok", vec!["grok"], vec![under_home(".grok")]), + "copilot" => ( + "GitHub Copilot", + vec!["copilot"], + vec![under_home(".copilot/session-store.db"), under_home(".copilot/config.json")], + ), + // No binary on PATH to look for: the CLI is launched by the IDE and + // its server is fetched per run, so the state directory is the only + // proof it is here - which is why detection takes paths as well. + "antigravity" => ( + "Antigravity", + vec!["antigravity"], + vec![under_home(".gemini/antigravity-cli")], + ), + other => (Box::leak(other.to_string().into_boxed_str()), vec![], vec![]), + } +} + +#[derive(Clone, Default)] +struct Presence { + present: bool, +} + +fn detect_agents() -> HashMap<String, Presence> { + ORDER + .iter() + .map(|name| { + let (_, bins, paths) = agent_spec(name); + let has_bin = bins.iter().any(|b| tc::missing(&[b]).is_empty()); + let has_data = paths.iter().any(|p| std::path::Path::new(p).exists()); + ( + name.to_string(), + Presence { + present: has_bin || has_data, + }, + ) + }) + .collect() +} + +/// The tabs to draw. +/// +/// Empty `agents` discovers every agent this machine actually has. Naming +/// them instead fixes both the set and the order, whether or not they are +/// installed - if you listed it, you want the tab. Falls back to everything +/// known if the result would be empty, because a widget with no tabs +/// teaches nothing and the likeliest cause is a typo. +fn visible_agents(found: &HashMap<String, Presence>, cfg: &Config) -> Vec<String> { + let known: Vec<&str> = ORDER.to_vec(); + let named: Vec<String> = cfg + .agents + .iter() + .filter(|n| known.contains(&n.as_str())) + .cloned() + .collect(); + let chosen: Vec<String> = if named.is_empty() { + ORDER + .iter() + .filter(|n| found.get(**n).is_some_and(|x| x.present)) + .map(|n| n.to_string()) + .collect() + } else { + named + }; + let shown: Vec<String> = chosen + .into_iter() + .filter(|n| !cfg.exclude_agents.contains(n)) + .collect(); + // The summary leads and is never discovered or excluded: it is not an + // agent, it is the view across whichever agents there turn out to be. + let mut out = vec![SUMMARY_TAB.to_string()]; + if shown.is_empty() { + out.extend(ORDER.iter().map(|n| n.to_string())); + } else { + out.extend(shown); + } + out +} + +/// Names in the config that match no agent we know how to read. +fn config_complaints(cfg: &Config) -> String { + let mut bad: Vec<String> = cfg + .agents + .iter() + .chain(cfg.exclude_agents.iter()) + .filter(|n| !ORDER.contains(&n.as_str())) + .cloned() + .collect(); + if bad.is_empty() { + return String::new(); + } + bad.sort(); + bad.dedup(); + format!( + "unknown agent in config: {} (known: {})", + bad.join(", "), + ORDER.join(", ") + ) +} + +fn tab_bar( + active: &str, + installed: &HashMap<String, Presence>, + tabs: &[String], + w: usize, + p: &Palette, +) -> String { + // Brackets as well as the tint: which tab is open must not depend on a + // background colour surviving. A dot marks an agent that is installed. + let mut parts: Vec<(String, String)> = vec![(tc::RST.to_string(), " ".into())]; + for name in tabs { + let here = name == active; + let have = installed.get(name).is_some_and(|x| x.present); + parts.push(( + if here { + format!("{}{}", tc::bg(38, 56, 76), p.accent) + } else { + p.dim.clone() + }, + if here { + format!("[{}]", name.to_uppercase()) + } else { + format!(" {} ", name.to_uppercase()) + }, + )); + if name == SUMMARY_TAB { + parts.push((p.grid.clone(), " ".into())); + continue; + } + parts.push(( + if have { p.ok.clone() } else { p.grid.clone() }, + if have { "·".into() } else { " ".to_string() }, + )); + } + let refs: Vec<(&str, String)> = parts.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + tc::seg(&refs, w - 1) +} + +/// Where a tab cursor lands after moving `by` tabs among `count`. +/// +/// rem_euclid rather than %, because the left key drives this negative +/// and Rust's % keeps the sign where Python's does not. Named rather than +/// inlined so a test can reach the arithmetic the loop actually runs. +fn step_tab(at: i64, by: i64, count: usize) -> usize { + if count == 0 { + return 0; + } + (at + by).rem_euclid(count as i64) as usize +} + +/// What to show before the first poll lands. +/// +/// Every tab's empty state is a statement of fact - no stats cache, no +/// rollouts, no agent publishing a quota - and each of them is false while +/// the first read is still running. The first read is also the slow one, +/// which is more than long enough for a wrong answer to be read and +/// believed. +fn loading_rows(w: usize, tick: usize, p: &Palette) -> Vec<String> { + let mut rows = vec![ + tc::seg( + &[ + (p.accent.as_str(), format!(" {}", SPINNER[tick % SPINNER.len()])), + (p.txt.as_str(), " reading local state and quotas".into()), + ], + w - 1, + ), + String::new(), + ]; + for part in wrap_text( + "The first pass is the slow one: Claude's transcripts run to hundreds \ + of megabytes and Cursor's usage events are paged a thousand at a \ + time. Both are cached afterwards.", + w.saturating_sub(4).max(20), + ) { + rows.push(tc::seg(&[(p.dim.as_str(), format!(" {}", part))], w - 1)); + } + rows +} + +fn main() { + tc::maybe_help(include_str!("usage_help.txt")); + let cfg = read_config(); + let mut refresh = cfg.refresh; + let args: Vec<String> = std::env::args().skip(1).collect(); + if args.len() >= 2 && (args[0] == "-n" || args[0] == "--refresh") { + refresh = tc::poll_secs(args[1].parse().unwrap_or(refresh), refresh); + } + + let p = palette(); + let state = Arc::new(Mutex::new(vendors::State::default())); + let wake = Arc::new((Mutex::new(false), Condvar::new())); + let poller = Arc::clone(&state); + let poller_wake = Arc::clone(&wake); + let poller_cfg = cfg.clone(); + std::thread::spawn(move || { + let mut caches = shared::Caches::default(); + loop { + // A poller that dies takes its explanation with it, and an empty + // board looks exactly like a machine with no agents on it. + let read = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + vendors::read_all(&mut caches, &poller_cfg) + })); + match read { + Ok(found) => { + if let Ok(mut g) = poller.lock() { + *g = found; + g.fetched = now(); + } + } + Err(_) => { + if let Ok(mut g) = poller.lock() { + g.err = "poller stopped - see the pane it was started from".into(); + } + return; + } + } + let (lock, cond) = &*poller_wake; + let mut asked = match lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + if !*asked { + asked = match cond.wait_timeout(asked, Duration::from_secs_f64(refresh)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + *asked = false; + } + }); + + tc::setup(); + let mut keyboard = tc::Keyboard::new(); + // Signed, because the left key has to be able to go below zero and + // wrap; rem_euclid then brings it back into range the way Python's + // % does for a negative index. + let (mut active, mut tick) = (0i64, 0usize); + // Switching tabs lands at the top of the new one. + // + // This used to be one offset per tab, kept so that switching away and + // back returned you to where you were reading. In use that is the wrong + // trade: the tabs are different lengths and different shapes, so a + // remembered offset from a forty-row tab opens the next one part-way + // down with its heading scrolled off, and the first thing a reader does + // on arriving somewhere new is look at the top of it. Only one offset is + // needed now, carried across frames of the same tab and dropped the + // moment the tab changes. + let (mut carried, mut shown) = (0usize, String::new()); + + loop { + tick += 1; + // Scrolling is applied after the frame is built, not here: a page is + // however many body rows this pane turned out to have, and that is + // not known until the tab has been rendered and the footer packed. + let mut moves: Vec<i64> = Vec::new(); + let (mut to_top, mut to_bottom) = (false, false); + let mut pages: Vec<i64> = Vec::new(); + for key in keyboard.poll() { + match key.as_str() { + "q" | "Q" => { + keyboard.restore(); + tc::restore_screen(); + return; + } + "right" | "tab" | "l" => active += 1, + "left" | "h" => active -= 1, + "up" | "k" => moves.push(-1), + "down" | "j" => moves.push(1), + "pgup" => pages.push(-1), + "pgdn" => pages.push(1), + "home" => to_top = true, + "end" => to_bottom = true, + "r" | "R" => { + let (lock, cond) = &*wake; + if let Ok(mut asked) = lock.lock() { + *asked = true; + cond.notify_all(); + } + } + _ => {} + } + } + + let (w, h) = tc::size(); + let snapshot = match state.lock() { + Ok(g) => g.clone(), + Err(_) => return, + }; + let mut rows = vec![tc::title("agent usage", w, &p.agent)]; + let tabs = visible_agents(&snapshot.installed, &cfg); + let at = step_tab(active, 0, tabs.len()); + active = at as i64; + let name = tabs[at].clone(); + let hidden = ORDER + .iter() + .filter(|n| { + snapshot.installed.get(**n).is_some_and(|x| x.present) + && !tabs.contains(&n.to_string()) + }) + .count(); + + let status_at = rows.len(); + rows.push(String::new()); // filled in once the scroll is resolved + let gripe = if snapshot.err.is_empty() { + config_complaints(&cfg) + } else { + snapshot.err.clone() + }; + if !gripe.is_empty() { + rows.push(tc::seg(&[(p.bad.as_str(), format!(" ! {}", gripe))], w - 1)); + } + rows.push(tab_bar(&name, &snapshot.installed, &tabs, w, &p)); + rows.push(String::new()); + + let body = if snapshot.fetched <= 0.0 { + loading_rows(w, tick, &p) + } else { + vendors::tab_body(&name, &snapshot, w, h, &cfg, &p) + }; + + let mut hints: Vec<Vec<(&str, String)>> = vec![ + vec![(p.accent.as_str(), "←→".into()), (p.dim.as_str(), " agent".into())], + vec![(p.accent.as_str(), "↑↓".into()), (p.dim.as_str(), " scroll".into())], + vec![(p.dim.as_str(), "[r]efresh".into())], + vec![(p.dim.as_str(), "[q]uit".into())], + ]; + // The footer is packed once, with the scroll hint always counted, so + // the body's height does not change when scrolling becomes possible. + let reserved = tc::pack_hints(&hints, w - 2, " ").len(); + let avail = h.saturating_sub(rows.len() + reserved).max(1); + let top = body.len().saturating_sub(avail); + let mut off = if name == shown { carried.min(top) } else { 0 }; + if to_top { + off = 0; + } + if to_bottom { + off = top; + } + for page in &pages { + let step = avail.saturating_sub(1).max(1) as i64; + off = (off as i64 + page * step).clamp(0, top as i64) as usize; + } + for move_ in &moves { + off = (off as i64 + move_).clamp(0, top as i64) as usize; + } + off = off.min(top); + carried = off; + shown = name.clone(); + + let view: Vec<String> = body.iter().skip(off).take(avail).cloned().collect(); + let where_ = if top > 0 { + // Never let a partial view read as the whole tab, and say which + // way there is more: an arrow simply absent at the top of a long + // tab looks the same as a tab that ends there. + format!( + " {}-{} of {} {}{}", + off + 1, + off + view.len(), + body.len(), + if off > 0 { "▲" } else { " " }, + if off < top { "▼" } else { " " } + ) + } else { + hints.retain(|x| x[0].1 != "↑↓"); + String::new() + }; + // The scroll position goes last on this line but matters most, so + // the legend stands down to make room rather than being clipped. + let base = format!(" local state · live quota · read {} ago", ago(snapshot.fetched)); + let hidden_txt = if hidden > 0 { + format!(" {} hidden by config", hidden) + } else { + String::new() + }; + let mut legend = " · = detected".to_string(); + if base.len() + hidden_txt.len() + legend.len() + where_.len() > w - 1 { + legend.clear(); + } + rows[status_at] = tc::seg( + &[ + (p.dim.as_str(), base), + (p.dim.as_str(), legend), + (p.dim.as_str(), hidden_txt), + (p.accent.as_str(), where_), + ], + w - 1, + ); + + let mut footer: Vec<String> = tc::pack_hints(&hints, w - 2, " ") + .into_iter() + .map(|l| format!(" {}", l)) + .collect(); + // Padded back to the height already reserved, so dropping the scroll + // hint does not lift the footer off the bottom of the pane. + while footer.len() < reserved { + footer.insert(0, String::new()); + } + rows.extend(view); + while rows.len() < h.saturating_sub(footer.len()) { + rows.push(String::new()); + } + rows.extend(footer); + rows.truncate(h); + tc::draw(&rows, w, h); + std::thread::sleep(Duration::from_millis(300)); + } +} + +// Kept in a directory of its own rather than beside this file: anything +// dropped straight into src/bin/ risks being taken for another binary. One +// module per agent, because they share only the shape the summary screen +// compares them in - and because five readers being written at once should +// not be five edits to the same file. +#[path = "usage/shared.rs"] +mod shared; +#[path = "usage/antigravity.rs"] +mod antigravity; +#[path = "usage/claude.rs"] +mod claude; +#[path = "usage/codex.rs"] +mod codex; +#[path = "usage/copilot.rs"] +mod copilot; +#[path = "usage/cursor.rs"] +mod cursor; +#[path = "usage/grok.rs"] +mod grok; +#[path = "usage/vendors.rs"] +mod vendors; + +#[cfg(test)] +mod tests { + + /// Under a day, say the hours. The quota headings used to render this + /// span three different ways - "~0.9 days", a truncated "0d", and + /// "resets today" - and all three round away the part a reader is + /// asking for when the reset is close. + #[test] + fn a_span_under_a_day_is_hours_and_minutes() { + assert_eq!(left_span(22.0 * 3600.0 + 6.0 * 60.0), "22h 6m"); + assert_eq!(left_span(45.0 * 60.0), "45m"); + assert_eq!(left_span(3600.0), "1h 0m"); + // A day or more keeps days, which is what that range wants. + assert_eq!(left_span(5.0 * 86400.0 + 12.0 * 3600.0), "5d 12h"); + assert_eq!(left_span(86400.0), "1d 0h"); + // The boundary the old code fell off: just under a day is not "0d". + let almost = 86400.0 - 60.0; + assert_eq!(left_span(almost), "23h 59m"); + assert!(!left_span(almost).starts_with('0')); + } + use super::*; + + #[test] + fn a_model_takes_the_longest_matching_rate() { + let none: HashMap<String, Rate> = HashMap::new(); + // claude-opus-4 must not shadow claude-opus-4-8. + let (rate, origin) = rate_for("claude-opus-4-8", &none); + assert_eq!(origin, "list"); + assert_eq!(rate.unwrap().get("input"), Some(&5.0)); + // A model name with a suffix still matches its family. + let (rate, _) = rate_for("claude-sonnet-5-20260101", &none); + assert_eq!(rate.unwrap().get("output"), Some(&10.0)); + // Config wins outright over the list. + let mut mine: HashMap<String, Rate> = HashMap::new(); + mine.insert( + "claude-opus-5".into(), + [("input".to_string(), 99.0)].into_iter().collect(), + ); + let (rate, origin) = rate_for("claude-opus-5", &mine); + assert_eq!(origin, "config"); + assert_eq!(rate.unwrap().get("input"), Some(&99.0)); + } + + #[test] + fn a_model_with_no_published_price_reports_as_unpriced() { + // Spark is explicitly not on the API, so prefix matching must not + // hand it its family's rate - that would be a number nobody + // published, which is the one thing this widget must never show. + let none: HashMap<String, Rate> = HashMap::new(); + assert!(rate_for("gpt-5.3-codex-spark", &none).0.is_none()); + // Unless the reader asserts one themselves. + let mut mine: HashMap<String, Rate> = HashMap::new(); + mine.insert( + "gpt-5.3-codex-spark".into(), + [("input".to_string(), 1.0)].into_iter().collect(), + ); + assert!(rate_for("gpt-5.3-codex-spark", &mine).0.is_some()); + } + + #[test] + fn a_cost_is_the_sum_of_its_priced_kinds() { + let rate: Rate = [ + ("input".to_string(), 5.0), + ("output".to_string(), 25.0), + ("cache_read".to_string(), 0.5), + ] + .into_iter() + .collect(); + let mut tokens = empty_tokens(); + tokens.insert("input".into(), 1_000_000.0); + tokens.insert("output".into(), 2_000_000.0); + tokens.insert("cache_read".into(), 10_000_000.0); + // 5 + 50 + 5 + assert!((cost_of(&tokens, &rate) - 60.0).abs() < 1e-9); + // A kind the card does not price contributes nothing rather than + // falling back to another kind's rate. + tokens.insert("cache_write".into(), 9_999_999.0); + assert!((cost_of(&tokens, &rate) - 60.0).abs() < 1e-9); + } + + #[test] + fn a_small_percentage_is_not_rounded_into_nothing() { + // A real 0.03% and an empty section have to be tellable apart. + assert_eq!(pct_text(0.0), " 0%"); + assert_eq!(pct_text(0.03), " 0.03%"); + assert_eq!(pct_text(0.4), " 0.40%"); + assert_eq!(pct_text(4.2), " 4.2%"); + assert_eq!(pct_text(71.0), " 71%"); + // Always six cells, whatever the value. + for pct in [0.0, 0.03, 4.2, 71.0, 100.0] { + assert_eq!(pct_text(pct).chars().count(), 6, "{}", pct); + } + } + + #[test] + fn a_pace_figure_waits_until_the_window_means_something() { + let window = Some(7.0 * 86400.0); + // Ten minutes into a week, every number looks like a catastrophe. + let just_started = Some(now() + 7.0 * 86400.0 - 600.0); + assert_eq!(lead(1.0, window, just_started), None); + // Half way through, spending a third is a real cushion. + let half = Some(now() + 3.5 * 86400.0); + let got = lead(33.0, window, half).expect("a pace"); + assert!((got - 17.0).abs() < 1.0, "got {}", got); + // And spending two thirds is not. + assert!(lead(67.0, window, half).expect("a pace") < 0.0); + // Nothing to say without both halves. + assert_eq!(lead(50.0, None, half), None); + assert_eq!(lead(50.0, window, None), None); + } + + #[test] + fn a_span_says_what_it_is_rather_than_zero() { + assert_eq!(span_ms(90_000.0), "1m"); + assert_eq!(span_ms(3_600_000.0), "1h 0m"); + assert_eq!(span_ms(90_000_000.0), "1d 1h 0m"); + // A couple of seconds of generation is not "0m". + assert_eq!(span_ms(2_400.0), "2.4s"); + } + + #[test] + fn big_numbers_stay_readable() { + assert_eq!(big_num(999.0), "999"); + assert_eq!(big_num(1_500.0), "1.5k"); + assert_eq!(big_num(2_400_000.0), "2.4M"); + assert_eq!(big_num(7_100_000_000.0), "7.1B"); + } + + #[test] + fn a_timestamp_is_read_whichever_shape_it_arrives_in() { + // These APIs mix Z with +00:00 in the same response, and Go writes + // nanoseconds where the parser takes six digits. + let want = iso_epoch("2026-08-23T04:15:00+00:00").expect("offset form"); + assert_eq!(iso_epoch("2026-08-23T04:15:00Z"), Some(want)); + // The two spellings of the same zone must give the same answer to + // the same precision. This asserted that the nanosecond form + // equalled the whole second - which is the fraction being thrown + // away, and adjacent records then look 0 or 1 second apart when a + // rate is computed by dividing tokens by that gap. + let fine = iso_epoch("2026-08-23T04:15:00.123456789Z").expect("nanoseconds"); + assert!((fine - (want + 0.123456)).abs() < 1e-6, "got {}", fine - want); + assert_eq!(iso_epoch("2026-08-23T04:15:00.123456+00:00"), Some(fine)); + assert!(iso_epoch("").is_none()); + assert!(iso_epoch("not a date").is_none()); + } + + #[test] + fn the_left_key_wraps_to_the_last_tab_at_every_tab_count() { + // How the loop moves between tabs: a signed cursor brought back + // into range with rem_euclid. Checked across counts because the + // fault this replaced was right for powers of two and wrong for + // everything else - a test at one width would have been a coin + // toss. + for count in 2usize..10 { + let n = count as i64; + assert_eq!(step_tab(0, -1, count), count - 1, "left from the first of {}", count); + assert_eq!(step_tab(n - 1, 1, count), 0, "right from the last of {}", count); + for at in 0..n { + let back = step_tab(at, -1, count) as i64; + assert_eq!(step_tab(back, 1, count) as i64, at, "there and back from {}", at); + } + } + // An empty tab list must not index anything. + assert_eq!(step_tab(0, -1, 0), 0); + } + + #[test] + fn a_bar_marks_where_an_even_burn_would_be() { + let p = palette(); + let plain = |parts: &[(String, String)]| -> String { + parts.iter().map(|(_, t)| t.clone()).collect() + }; + // Half spent, half the window gone: the mark sits at the boundary. + let bar = paced_bar(0.5, Some(0.5), 10, Some((240, 132, 84)), &p); + let drawn = plain(&bar); + assert_eq!(drawn.chars().count(), 10); + assert!(drawn.contains('┃')); + // No window information means no mark, not a mark at zero. + let bare = plain(&paced_bar(0.5, None, 10, None, &p)); + assert!(!bare.contains('┃')); + assert_eq!(bare.chars().count(), 10); + } + + #[test] + fn a_calendar_counts_streaks_over_the_range_not_the_entries() { + let p = palette(); + let day = |s: &str| NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap(); + let totals: HashMap<NaiveDate, f64> = [ + (day("2026-08-01"), 10.0), + (day("2026-08-02"), 5.0), + // 3 August is absent, which is a gap rather than a zero. + (day("2026-08-04"), 7.0), + (day("2026-08-05"), 3.0), + (day("2026-08-06"), 1.0), + ] + .into_iter() + .collect(); + let cal = day_calendar(&totals, 60, HEAT_STEPS, None, &p).expect("a calendar"); + assert_eq!(cal.span, 6); + assert_eq!(cal.active, 5); + assert_eq!(cal.longest, 3); + assert_eq!(cal.current, 3); + assert_eq!(cal.best, Some(day("2026-08-01"))); + // Seven weekday rows plus the month strip. + assert_eq!(cal.rows.len(), 8); + } + + #[test] + fn a_sentence_wraps_rather_than_losing_its_end() { + assert_eq!(wrap_text("one two three", 7), vec!["one two", "three"]); + assert_eq!(wrap_text("", 10), vec![""]); + // A labelled value's continuation lines sit under the value. + let got = wrap_pair("plan", "a rather long enterprise sku here", 6, 30); + assert_eq!(got[0].0, "plan"); + assert_eq!(got[1].0, ""); + assert!(got.len() > 1); + } +} diff --git a/widgets/src/bin/usage/antigravity.rs b/widgets/src/bin/usage/antigravity.rs new file mode 100644 index 0000000..a6b0840 --- /dev/null +++ b/widgets/src/bin/usage/antigravity.rs @@ -0,0 +1,1585 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Antigravity: its conversation databases and the Code Assist quota. +//! +//! Nothing here is a token count, because Antigravity records none. The +//! conversation stores hold steps the agent took, the history file holds +//! prompts typed, and the only percentages on the tab come from a language +//! server that exists in memory while the agent runs and is gone after. + +use std::collections::HashSet; +use std::time::Duration; + +use rusqlite::{Connection, OpenFlags}; +use toys_core as tc; + +use crate::shared::*; +use crate::*; + +/// Where the CLI keeps everything: its token, its conversations and the +/// prompt history. Also the only proof the agent is installed, since it is +/// launched by the IDE and puts no binary on PATH. +fn antigravity_dir() -> String { + under_home(".gemini/antigravity-cli") +} + +fn token_path() -> String { + format!("{}/antigravity-oauth-token", antigravity_dir()) +} + +const CODE_ASSIST_API: &str = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; + +/// How long to wait for a started `agy` to serve its quota. +/// +/// Measured at a few seconds on this machine: the ports open immediately +/// and answer nothing, and the quota service comes up behind them. So the +/// wait is on an endpoint that parses, never on a port that exists. +const AGY_READY: f64 = 25.0; +/// A started `agy` is expensive next to reading a socket, so its answer is +/// held far longer than the local probe's. +const AGY_TTL: f64 = 900.0; + +/// Where the CLI might be, in the order CodexBar looks. +fn agy_path() -> Option<String> { + if let Ok(named) = std::env::var("ANTIGRAVITY_CLI_PATH") { + if !named.is_empty() && std::path::Path::new(&named).exists() { + return Some(named); + } + } + let mut seen = Vec::new(); + if let Ok(path) = std::env::var("PATH") { + seen.extend(path.split(':').map(|dir| format!("{}/agy", dir))); + } + seen.push(under_home(".local/bin/agy")); + seen.push("/opt/homebrew/bin/agy".into()); + seen.push("/usr/local/bin/agy".into()); + seen.into_iter().find(|p| std::path::Path::new(p).exists()) +} + +/// Every listening TCP port belonging to `pid` or anything it started. +/// +/// Descendants matter: the port that answers is not always the process that +/// was launched. On this machine the quota came from a child, so a search +/// scoped to the pid alone finds two ports that answer nothing and gives up. +fn ports_under(pid: i32) -> Vec<u16> { + let mut family = vec![pid.to_string()]; + for entry in std::fs::read_dir("/proc").into_iter().flatten().flatten() { + let child = entry.file_name().to_string_lossy().to_string(); + if !child.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let Ok(stat) = std::fs::read_to_string(format!("/proc/{}/stat", child)) else { + continue; + }; + // The command name sits in brackets and may itself contain spaces, + // so the fields after it are found from the last ')' rather than by + // splitting the whole line. + let Some(after) = stat.rsplit_once(')') else { continue }; + if after.1.split_whitespace().nth(1) == Some(&pid.to_string()) { + family.push(child); + } + } + let mut inodes = std::collections::HashSet::new(); + for who in &family { + for fd in std::fs::read_dir(format!("/proc/{}/fd", who)) + .into_iter() + .flatten() + .flatten() + { + let Ok(target) = std::fs::read_link(fd.path()) else { + continue; + }; + if let Some(rest) = target.to_string_lossy().strip_prefix("socket:[") { + inodes.insert(rest.trim_end_matches(']').to_string()); + } + } + } + let mut ports = Vec::new(); + for table in ["/proc/net/tcp", "/proc/net/tcp6"] { + let Ok(body) = std::fs::read_to_string(table) else { + continue; + }; + ports.extend(listening_ports(&body, &inodes)); + } + ports.sort_unstable(); + ports.dedup(); + ports +} + +/// Start `agy`, read the quota it serves, and stop it again. +/// +/// A pseudo-terminal is not decoration. Started with its input on +/// /dev/null the CLI opens ports that answer nothing for as long as you +/// wait; given a pty it serves the quota within seconds. Both were measured +/// here before this was written. +/// +/// The child is ours alone: it is killed by the pid we were handed and +/// nothing is matched by name, so a CLI the reader started themselves can +/// never be shut by this. That one is found by the ordinary local probe +/// long before this runs. +fn agy_quota() -> Result<Vec<serde_json::Value>, String> { + let Some(path) = agy_path() else { + return Err("no `agy` on this machine - install the Antigravity CLI, \ + or set ANTIGRAVITY_CLI_PATH" + .into()); + }; + let mut master: libc::c_int = 0; + // SAFETY: forkpty writes the master fd through the pointer and returns + // in both processes, 0 in the child. The child immediately execs and + // never returns, so nothing here runs twice. + let pid = unsafe { + libc::forkpty( + &mut master, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if pid < 0 { + return Err("could not start `agy`: no pseudo-terminal available".into()); + } + if pid == 0 { + // SAFETY: child side. Replace this process with the CLI; if that + // fails there is nothing to return to, so it exits. + unsafe { + let c = std::ffi::CString::new(path.clone()).unwrap_or_default(); + let argv = [c.as_ptr(), std::ptr::null()]; + libc::execv(c.as_ptr(), argv.as_ptr()); + libc::_exit(127); + } + } + let out = agy_wait(pid, master); + // SAFETY: our own child, by pid. Reaped so it cannot be left a zombie + // for as long as the widget runs. + unsafe { + libc::kill(pid, libc::SIGKILL); + libc::waitpid(pid, std::ptr::null_mut(), 0); + libc::close(master); + } + out +} + +/// Poll a started CLI until its quota endpoint parses, or time runs out. +fn agy_wait(pid: i32, master: libc::c_int) -> Result<Vec<serde_json::Value>, String> { + // The pty has to be drained or the child blocks writing into a full + // buffer and never finishes starting. Nothing it prints is read for + // meaning - this widget does not scrape terminal output. + // SAFETY: setting O_NONBLOCK on a descriptor we own. + unsafe { + let flags = libc::fcntl(master, libc::F_GETFL); + libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + let mut scratch = [0u8; 8192]; + let deadline = now() + AGY_READY; + let mut ports_seen = false; + while now() < deadline { + // SAFETY: reading into our own buffer from our own descriptor. + loop { + let got = unsafe { + libc::read(master, scratch.as_mut_ptr() as *mut libc::c_void, scratch.len()) + }; + if got <= 0 { + break; + } + } + std::thread::sleep(Duration::from_millis(700)); + for port in ports_under(pid) { + ports_seen = true; + let url = format!("http://127.0.0.1:{}{}", port, ANTIGRAVITY_RPC); + let Some(got) = post_json(&url, &[("Content-Type", "application/json")], "{}", 4) + else { + continue; + }; + let body = if got["response"].as_object().is_some_and(|o| !o.is_empty()) { + &got["response"] + } else { + &got + }; + if let Some(groups) = body["groups"].as_array() { + if !groups.is_empty() { + return Ok(groups.clone()); + } + } + } + } + Err(if ports_seen { + "`agy` started but served no quota - it may not be signed in".into() + } else { + "`agy` started and opened no port".into() + }) +} + +/// The same quota summary the language server answers, from Google. +/// +/// This was thought not to exist. The tab said so, and said it in the +/// strongest terms - that with the app closed there was nothing to ask, on +/// any machine, for anybody - because the only quota this widget knew about +/// came out of a process that dies with the IDE. +/// +/// It does exist, it is on the endpoint the tier already comes from, and the +/// body is empty rather than the `pluginType` metadata `loadCodeAssist` +/// wants: sending that here is a 400 naming `metadata` as an unknown field, +/// which reads exactly like an endpoint that is not there. +/// +/// What comes back is the same shape the language server sends, group for +/// group and bucket for bucket, so it needs no parser of its own. +const REMOTE_QUOTA_API: &str = + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + +/// The Connect method the language server answers the quota on. +const ANTIGRAVITY_RPC: &str = + "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary"; + +/// How long each window the server names actually is. It reports the name +/// and the fraction left but not the span, and without the span there is no +/// pace to compare a fill against. +const ANTIGRAVITY_WINDOWS: &[(&str, f64)] = &[("weekly", 7.0 * 86400.0), ("5h", 5.0 * 3600.0)]; + +fn window_secs(name: &str) -> Option<f64> { + ANTIGRAVITY_WINDOWS + .iter() + .find(|(k, _)| *k == name) + .map(|(_, s)| *s) +} + +/// What Antigravity has recorded here, and what its server says is left. +#[derive(Clone, Default)] +pub struct Data { + /// Which Code Assist tier the account is on. `None` means the call did + /// not happen or did not answer, which the tab says out loud. + live: Option<serde_json::Value>, + /// Why there is no tier, when there is none. The three reasons want + /// three different things from the reader - sign in, run the CLI, or + /// wait - and the old line offered "expired or the call failed", which + /// is the widget admitting it never looked. + tier_why: Missing, + /// What the endpoint said, when it said anything. Empty otherwise. + tier_said: String, + /// The quota groups, empty when neither source answered. + quota: Vec<serde_json::Value>, + /// Why there is no quota, when there is none. Empty when there is. + quota_why: String, + /// Which of the three answered. They agree in shape and very nearly in + /// value, so nothing else can tell them apart - and a reader wondering + /// why there are numbers with the app shut deserves the answer. + quota_from: From, + /// How the CLI authenticated. Read once here rather than per frame: + /// the tab is redrawn on every keypress and this is a file on disk. + auth: String, + /// Conversation databases found. + sessions: usize, + /// How many of them answered. A database a running agent has locked is + /// not a database with no steps in it, so the two are counted apart. + counted: usize, + steps: f64, + prompts: usize, + last: f64, +} + +/// Whether a command line belongs to Antigravity's language server. +/// +/// The same match the Python makes with a regex, written out: `agy` or +/// `antigravity` at the very start of the line or straight after a path +/// separator, or `language_server` anywhere. Anchoring to the executable +/// keeps a process that merely names Antigravity in an argument - a grep, +/// an editor with the file open - from having its sockets probed. +fn is_language_server(cmd: &str) -> bool { + if cmd.contains("language_server") { + return true; + } + let bytes = cmd.as_bytes(); + for name in ["agy", "antigravity"] { + let mut at = 0usize; + while let Some(found) = cmd[at..].find(name) { + let start = at + found; + let end = start + name.len(); + let anchored = start == 0 || bytes[start - 1] == b'/'; + let bounded = match bytes.get(end) { + None => true, + Some(c) => !c.is_ascii_alphanumeric() && *c != b'_', + }; + if anchored && bounded { + return true; + } + at = start + 1; + } + } + false +} + +/// Listening sockets in a /proc/net/tcp table that belong to us. +/// +/// State 0A is LISTEN, column 9 is the socket inode, and the local address +/// carries the port as hex after the colon. Split out from the reading so +/// the parse can be tested against a table nobody's machine has to have. +fn listening_ports(table: &str, inodes: &HashSet<String>) -> Vec<u16> { + let mut out = Vec::new(); + for row in table.lines().skip(1) { + let cols: Vec<&str> = row.split_whitespace().collect(); + if cols.len() <= 9 || cols[3] != "0A" || !inodes.contains(cols[9]) { + continue; + } + if let Some(hex) = cols[1].split(':').nth(1) { + if let Ok(port) = u16::from_str_radix(hex, 16) { + out.push(port); + } + } + } + out +} + +/// TCP ports the running Antigravity language server listens on. +/// +/// The quota never reaches disk, but the process holding it serves an RPC +/// on localhost, so the port is the way in. Found by matching the process +/// then reading its listening sockets out of /proc - no lsof, no guessing +/// at a range, and nothing touched but our own machine. +fn antigravity_ports() -> Vec<u16> { + let mut inodes: HashSet<String> = HashSet::new(); + for entry in std::fs::read_dir("/proc").into_iter().flatten().flatten() { + let pid = entry.file_name().to_string_lossy().to_string(); + if pid.is_empty() || !pid.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let Ok(raw) = std::fs::read(format!("/proc/{}/cmdline", pid)) else { + continue; + }; + let cmd = String::from_utf8_lossy(&raw).replace('\0', " "); + if !is_language_server(&cmd) { + continue; + } + // Another user's file descriptors are not readable, and that is not + // an error worth reporting - it is simply not our process. + for fd in std::fs::read_dir(format!("/proc/{}/fd", pid)) + .into_iter() + .flatten() + .flatten() + { + let Ok(target) = std::fs::read_link(fd.path()) else { + continue; + }; + if let Some(rest) = target.to_string_lossy().strip_prefix("socket:[") { + inodes.insert(rest.trim_end_matches(']').to_string()); + } + } + } + if inodes.is_empty() { + return Vec::new(); + } + let mut ports: Vec<u16> = Vec::new(); + for table in ["/proc/net/tcp", "/proc/net/tcp6"] { + let Ok(body) = std::fs::read_to_string(table) else { + continue; + }; + ports.extend(listening_ports(&body, &inodes)); + } + ports.sort_unstable(); + ports.dedup(); + ports +} + +/// Weekly and five-hour limits, from the language server on localhost. +/// +/// The same figures Antigravity's own TUI prints. It speaks Connect over +/// plain HTTP on a loopback port - its TLS listener answers with a wrong +/// version number, so http is not a downgrade here, it is the protocol - +/// and the call leaves this machine no more than reading a file would. +/// +/// Found by reading how CodexBar does it (github.com/steipete/CodexBar), +/// after chasing the Google endpoint in the binary to a 404: the quota was +/// never a remote call to make, it was a local one. +fn antigravity_quota() -> Option<serde_json::Value> { + for port in antigravity_ports() { + let url = format!("http://127.0.0.1:{}{}", port, ANTIGRAVITY_RPC); + let Some(got) = post_json(&url, &[("Content-Type", "application/json")], "{}", 5) else { + continue; + }; + // Some builds wrap the payload and some do not, so an envelope is + // only unwrapped when it actually holds something. + let body = if got["response"].as_object().is_some_and(|o| !o.is_empty()) { + &got["response"] + } else { + &got + }; + // Anything else answering on a matched port is not this server, so + // the loop keeps going rather than settling for an empty reply. + if let Some(groups) = body["groups"].as_array() { + if !groups.is_empty() { + return Some(serde_json::Value::Array(groups.clone())); + } + } + } + None +} + +/// Which Code Assist tier the account is on. +/// +impl Data { + /// The reason the tier is missing, for callers outside this module - + /// the summary says it too, under a heading of Antigravity's own. + pub fn why_no_tier(&self) -> Missing { + if self.live.is_some() { Missing::Nothing } else { self.tier_why } + } +} + +/// Which source the quota on screen came from. +#[derive(Clone, Copy, Default, PartialEq)] +pub enum From { + /// A language server already running - the app's, or a CLI the reader + /// started. Nothing was launched and nothing left the machine. + #[default] + Local, + /// Google, on the endpoint the tier comes from. Works for as long as + /// the token lasts, which is an hour past the last run. + Google, + /// A CLI this widget started, read, and stopped again. + Agy, +} + +/// Why the tier is missing, in the three ways it can be. +#[derive(Clone, Copy, Default, PartialEq)] +pub enum Missing { + /// There is a tier; nothing is missing. + #[default] + Nothing, + /// No token file, or one with no access token in it. + NotSignedIn, + /// A token that has lapsed, with how long ago in seconds. + Expired(f64), + /// A good token and no answer. Google's, not ours. + NoAnswer, +} + + +/// The missing tier in one sentence, ending in what to do about it. +/// +/// The line this replaces read "no tier: the CLI's access token has expired +/// or the call failed", which names two causes, commits to neither, and asks +/// nothing of the reader. Each of these commits, because the widget can tell +/// them apart - it just was not looking. +pub fn tier_note(why: Missing) -> String { + match why { + Missing::Nothing => String::new(), + Missing::NotSignedIn => { + "no tier · Antigravity has not signed in on this machine yet. \ + Open it once and the tier appears here." + .into() + } + Missing::Expired(ago) => format!( + "no tier · Antigravity's access token expired {} ago. \ + It refreshes them itself - open it once and this fills in.", + left_span(ago) + ), + Missing::NoAnswer => { + "no tier · the token is good, but Google's Code Assist API did not \ + answer. Nothing to do here; it is retried every hour." + .into() + } + } +} + +/// Why Antigravity publishes no bar, for the summary screen. +/// +/// A missing tier is one reason and was the only one this said out loud, +/// so an account whose tier reads perfectly well produced an empty note +/// and "No quota published by: antigravity" with nothing after it. The +/// commoner reason by far is the other one: unlike every other agent here +/// Antigravity has no account-wide quota endpoint at all, and its +/// percentages live in a language server that exists only while the app is +/// running. Its own tab has always said so; the summary had not. +pub fn why_no_lane(d: &Data) -> String { + let tier = tier_note(d.why_no_tier()); + if !tier.is_empty() { + return tier; + } + // The remote attempt knows exactly which step failed, and that is more + // use than any sentence written in advance. + if !d.quota_why.is_empty() { + return format!("no quota · {}", d.quota_why); + } + "no quota · neither the language server inside the app nor Google answered.".into() +} + +/// The same, with the server's own words when there are any. +pub fn tier_note_said(why: Missing, said: &str) -> String { + let base = tier_note(why); + match (base.is_empty(), said.is_empty()) { + (true, _) => String::new(), + (false, true) => base, + (false, false) => format!("{} It said: {}.", base, said), + } +} + +/// What is wrong with the credential, read from the same file the call uses. +/// +/// Cheap enough to do every frame - it is a small JSON file - and it must +/// not be cached with the call: the call is held for an hour, and a token +/// that lapses inside that hour would otherwise be reported as an endpoint +/// that would not answer. +fn why_no_tier() -> Missing { + let Some(file) = read_json(&token_path()) else { + return Missing::NotSignedIn; + }; + let tok = &file["token"]; + if text(tok, "access_token").is_empty() { + return Missing::NotSignedIn; + } + match iso_epoch(&text(tok, "expiry")) { + Some(at) if at <= now() => Missing::Expired(now() - at), + _ => Missing::NoAnswer, + } +} + +fn antigravity_said() -> Result<serde_json::Value, String> { + let Some(file) = read_json(&token_path()) else { + return Err(String::new()); + }; + let tok = &file["token"]; + let access = text(tok, "access_token"); + let expiry = iso_epoch(&text(tok, "expiry")); + if access.is_empty() || expiry.is_some_and(|at| at <= now()) { + return Err(String::new()); + } + post_try(CODE_ASSIST_API, &access) +} + +/// The same call, keeping why it failed. +fn post_try(url: &str, access: &str) -> Result<serde_json::Value, String> { + post_body(url, access, "{\"metadata\":{\"pluginType\":\"GEMINI\"}}") +} + +/// The quota summary Google holds, when nothing local is serving one. +/// +/// Preferred second, not first: the language server is the app's own answer +/// and moves as it is used, while this is what Google has recorded. They +/// agree in shape and very nearly in value, so the tab cannot tell them +/// apart - which is the point, and why the row says which it read. +/// Everything that can be decided without leaving the machine, decided +/// before anything is cached. +/// +/// The reason has to survive a cache hit. `cached` holds a failure for a +/// while and does not re-run the closure that produced it, so a reason +/// captured inside that closure is gone by the next frame and the row falls +/// back to whatever generic sentence is left - which is how this said +/// "Google did not answer" about a token that had expired an hour earlier +/// and was never sent. +fn remote_token(allowed: bool) -> Result<String, String> { + if !allowed { + return Err("asking Google is off - set usage.antigravity_remote to true".into()); + } + let Some(file) = read_json(&token_path()) else { + return Err( + "Antigravity has not signed in on this machine - run `agy` once and sign in".into(), + ); + }; + let tok = &file["token"]; + let access = text(tok, "access_token"); + if access.is_empty() { + return Err("the Antigravity token file holds no session - sign in again".into()); + } + // A lapsed token is not sent. It would be refused, and the refusal would + // then be the only thing reported, which says less than the expiry does: + // this one names how long ago and what refreshes it. + if let Some(expiry) = iso_epoch(&text(tok, "expires")) { + if expiry <= now() { + return Err(expired_note(now() - expiry)); + } + } + if let Some(expiry) = iso_epoch(&text(tok, "expiry")) { + if expiry <= now() { + return Err(expired_note(now() - expiry)); + } + } + Ok(access) +} + +fn expired_note(ago: f64) -> String { + format!( + "Antigravity's token expired {} ago - it refreshes them itself, so open it \ + or run `agy` once and sign in", + left_span(ago) + ) +} + +/// The ask itself, once the credential is known to be worth sending. +fn remote_quota(access: &str) -> Result<Vec<serde_json::Value>, String> { + let got = post_body(REMOTE_QUOTA_API, access, "{}") + .map_err(|said| format!("Google refused the Antigravity token: {}", said))?; + match got["groups"].as_array() { + Some(groups) if !groups.is_empty() => Ok(groups.clone()), + // A 200 with nothing in it. Not a failure to report as one, but not + // a reading either, and the difference matters when the reader is + // deciding whether to go and open something. + _ => Err("Google answered with no quota groups for this account".into()), + } +} + +fn post_body(url: &str, access: &str, body: &str) -> Result<serde_json::Value, String> { + post_json_said( + url, + &[ + ("Authorization", &format!("Bearer {}", access)), + ("Content-Type", "application/json"), + // Google gates this response on the client string. Sent as + // plain terminal-toys it answers UNSUPPORTED_CLIENT and returns + // no tier at all; the parenthesised form is the conventional way + // to name the client being spoken for while still saying who is + // actually calling. + ("User-Agent", "terminal-toys (antigravity-cli)"), + ], + body, + 20, + ) +} + +/// How many steps one conversation store records, or nothing if it would +/// not open. +/// +/// Read-only on purpose: these are a live agent's working state, and this +/// widget has no business being able to write to them. A busy database +/// waits briefly and then gives up - the pane refreshes on a clock, so +/// blocking a poll for seconds to spare one number would cost every other +/// number on the wall. +fn conversation_steps(path: &str) -> Option<f64> { + let con = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?; + con.busy_timeout(Duration::from_millis(250)).ok()?; + let count: i64 = con + .query_row("select count(*) from steps", [], |row| row.get(0)) + .ok()?; + Some(count as f64) +} + +/// What the conversation stores record, which is activity and not cost. +/// +/// Each conversation is its own SQLite file with a `steps` table - one row +/// per step the agent took - so the counts are real work done. No table +/// anywhere carries a token count. +pub fn read(caches: &mut Caches, cfg: &Config) -> Data { + use std::os::unix::fs::MetadataExt; + // The refusal is cached with the failure, so it is shown for as long as + // the failure lasts rather than only on the frame the call was made. + let live = cached(caches, "antigravity", PLAN_TTL, || { + match antigravity_said() { + Ok(v) => Some(v), + Err(why) => Some(serde_json::json!({ "terminal_toys_refusal": why })), + } + }); + let said = live + .as_ref() + .map(|v| text(v, "terminal_toys_refusal")) + .unwrap_or_default(); + let live = if said.is_empty() { live } else { None }; + let mut d = Data { + tier_why: if live.is_some() { Missing::Nothing } else { why_no_tier() }, + tier_said: said, + live, + quota: cached(caches, "antigravity-quota", LIVE_TTL, antigravity_quota) + .and_then(|got| got.as_array().cloned()) + .unwrap_or_default(), + auth: read_json(&token_path()) + .map(|file| text(&file, "auth_method")) + .unwrap_or_default(), + ..Data::default() + }; + // Local first: it is the app's own answer and moves as the app is used. + // Google's is asked only when there is no server to ask, and is held far + // longer - it is a record rather than a live meter, and unlike the local + // one it is a request that leaves this machine. + // Three sources, cheapest first, and every one of them optional. + // + // A language server already running costs a socket read, so it is + // always tried. Google costs one request and works for as long as the + // token lasts. Starting the CLI costs a process and several seconds, so + // it is last however preferred it is - being last is not being + // disfavoured, it is being expensive. + if d.quota.is_empty() { + let mut why = String::new(); + match remote_token(cfg.antigravity_remote) { + // Decided before the cache, so it survives a held failure and + // is the same sentence on every frame until it changes. + Err(said) => why = said, + Ok(access) => { + let mut asked = String::new(); + let got = cached(caches, "antigravity-remote", PLAN_TTL, || { + match remote_quota(&access) { + Ok(groups) => Some(serde_json::Value::Array(groups)), + Err(said) => { + asked = said; + None + } + } + }) + .and_then(|got| got.as_array().cloned()); + match got { + Some(groups) => { + d.quota = groups; + d.quota_from = From::Google; + } + None => { + why = if asked.is_empty() { + "Google did not answer, and the refusal is still held".to_string() + } else { + asked + } + } + } + } + } + if d.quota.is_empty() && cfg.antigravity_start { + let mut asked = String::new(); + let got = cached(caches, "antigravity-agy", AGY_TTL, || match agy_quota() { + Ok(groups) => Some(serde_json::Value::Array(groups)), + Err(said) => { + asked = said; + None + } + }) + .and_then(|got| got.as_array().cloned()); + match got { + Some(groups) => { + d.quota = groups; + d.quota_from = From::Agy; + why.clear(); + } + // Both failed. The CLI's reason is the later and more + // specific one, so it wins - "Google refused the token" and + // "`agy` is not signed in" are the same fault said twice, + // and the second names what to do. + None => { + if !asked.is_empty() { + why = asked; + } + } + } + } + if d.quota.is_empty() { + d.quota_why = why; + } + } + let mut files: Vec<String> = std::fs::read_dir(format!("{}/conversations", antigravity_dir())) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path().to_string_lossy().to_string()) + .filter(|path| path.ends_with(".db")) + .collect(); + files.sort(); + d.sessions = files.len(); + for path in &files { + let Ok(meta) = std::fs::metadata(path) else { + continue; + }; + // The timestamp is taken before the query, so a conversation too + // busy to count still proves the agent ran. + d.last = d.last.max(meta.mtime() as f64); + if let Some(steps) = conversation_steps(path) { + d.steps += steps; + d.counted += 1; + } + } + let history = format!("{}/history.jsonl", antigravity_dir()); + if let Ok(body) = std::fs::read_to_string(&history) { + d.prompts = body.lines().filter(|line| !line.trim().is_empty()).count(); + if let Ok(meta) = std::fs::metadata(&history) { + d.last = d.last.max(meta.mtime() as f64); + } + } + d +} + +/// Every quota this agent publishes, for the summary screen. +/// +/// Grouped by model family on the tab, flattened here: the summary compares +/// lanes across agents, and "gemini weekly" says which family it is without +/// the group heading the tab can afford. +/// +/// Never marked stale. The language server answers only while Antigravity +/// is running and this reading is at most one refresh old, so unlike a +/// file-backed cache there is no old number here to warn about. +pub fn lanes(d: &Data) -> Vec<Lane> { + let mut out = Vec::new(); + for group in &d.quota { + let display = text(group, "displayName"); + let short = display + .split_whitespace() + .next() + .unwrap_or("?") + .to_lowercase(); + for bucket in group["buckets"].as_array().into_iter().flatten() { + let Some(left) = bucket["remainingFraction"].as_f64() else { + continue; + }; + let window = match text(bucket, "window") { + s if s.is_empty() => "?".to_string(), + s => s, + }; + out.push(Lane { + label: format!("{} {}", short, window), + pct: 100.0 * (1.0 - left), + window_secs: window_secs(&window), + reset: iso_epoch(&text(bucket, "resetTime")), + stale: false, + projected: false, + apart: false, + }); + } + } + out +} + +/// One bar per limit, grouped by the model family it covers. +/// +/// Shown as spent rather than the remaining fraction the RPC returns, so +/// red means the same here as on every other tab. Every plan reports every +/// family it covers, so a Gemini-only account still gets a Claude/GPT pair +/// sitting at 0% - they are real limits, not padding, and are left in. +fn antigravity_quota_rows( + groups: &[serde_json::Value], + from: From, + w: usize, + p: &Palette, +) -> Vec<String> { + if groups.is_empty() { + return Vec::new(); + } + // The long form names where the number comes from, which matters here + // more than elsewhere; the short one still says it is not this machine's + // own tally. Shortened before it can clip, as the other headers are. + let (mut note, short, tiny) = match from { + From::Google => ( + " · account-wide, from Google - the app is not running", + " · from Google", + " · remote", + ), + From::Agy => ( + " · account-wide, from `agy`, started for this reading", + " · from `agy`", + " · agy", + ), + From::Local => ( + " · account-wide, from the local language server", + " · from the local server", + " · local", + ), + }; + for shorter in [short, tiny] { + if 13 + "live".len() + note.chars().count() <= w.saturating_sub(1) { + break; + } + note = shorter; + } + let hue = agent_hue("antigravity"); + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── QUOTA ── ".into()), + (p.ok.as_str(), "live".into()), + (p.dim.as_str(), note.into()), + ], + w - 1, + )]; + for group in groups { + let buckets: Vec<&serde_json::Value> = group["buckets"] + .as_array() + .into_iter() + .flatten() + .filter(|b| b["remainingFraction"].as_f64().is_some()) + .collect(); + if buckets.is_empty() { + continue; + } + let name = match text(group, "displayName") { + s if s.is_empty() => "?".to_string(), + s => s, + }; + rows.push(tc::seg(&[(p.txt.as_str(), format!(" {}", name))], w - 1)); + let windows: Vec<String> = buckets + .iter() + .map(|b| match text(b, "window") { + s if s.is_empty() => "?".to_string(), + s => s, + }) + .collect(); + let label_w = windows.iter().map(|s| s.chars().count()).max().unwrap_or(1); + for (bucket, window) in buckets.iter().zip(&windows) { + let pct = 100.0 * (1.0 - bucket["remainingFraction"].as_f64().unwrap_or(0.0)); + let used = (pct / 100.0).clamp(0.0, 1.0); + let secs = window_secs(window); + let reset = iso_epoch(&text(bucket, "resetTime")); + let mut when = match reset { + None => String::new(), + Some(at) if at - now() > 0.0 => format!("resets in {}", left_span(at - now())), + Some(_) => "resetting".to_string(), + }; + let mut room = w as i64 - 36 - label_w as i64; + if room < 8 { + // Below the bar's floor the row cannot shrink further, so the + // reset stands down rather than being cut in half. + when = String::new(); + room = w as i64 - 20 - label_w as i64; + } + let mut line: Vec<(String, String)> = vec![( + p.dim.clone(), + format!(" {} ", tc::pad(window, label_w)), + )]; + line.extend(paced_bar( + used, + elapsed_of(secs, reset), + room.max(8) as usize, + hue, + p, + )); + line.push((pct_colour(pct, hue, p), pct_text(pct))); + let (pace_colour, pace_txt) = pace_cell(lead(pct, secs, reset), p); + line.push((pace_colour, pace_txt)); + line.push((p.dim.clone(), format!(" {}", when))); + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + } + rows.push(String::new()); + rows +} + +fn antigravity_plan_rows(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let live = d.live.clone().unwrap_or(serde_json::Value::Null); + let filled = |v: &serde_json::Value| v.as_object().is_some_and(|o| !o.is_empty()); + if !filled(&live["currentTier"]) && !filled(&live["paidTier"]) { + return Vec::new(); + } + let (cur, paid) = (&live["currentTier"], &live["paidTier"]); + let mut pairs: Vec<(String, String)> = Vec::new(); + for (label, value) in [ + ("code assist tier", text(cur, "id")), + // paidTier is the Google AI subscription behind the account, which + // is a different thing from the Code Assist tier and can disagree + // with it - free-tier here, while the account is on Ultra. Both are + // stated. + ("google ai plan", text(paid, "name")), + ("project", text(&live, "cloudaicompanionProject")), + ("auth", d.auth.clone()), + ] { + if !value.is_empty() { + pairs.push((label.into(), value)); + } + } + // The two tiers can disagree - free-tier beside Google AI Ultra is + // normal, since one is GCP licensing and the other a consumer plan - but + // that is a paragraph the docs can carry, not four lines on every frame. + let headline = match text(cur, "name") { + s if s.is_empty() => text(paid, "name"), + s => s, + }; + plan_rows(&headline, &pairs, w, "", None, "", p) +} + +/// The caveat on the step count, in the longest form that fits. +/// +/// Shortened rather than clipped, for the reason the reset above it is: +/// "from 7 of 9 conversa" is not a shorter sentence, it is a broken one, +/// and this qualifies the one number on the tab that can be incomplete. +fn steps_note(d: &Data, room: i64) -> String { + if d.sessions == d.counted { + return String::new(); + } + let forms = if d.counted == 0 { + [ + format!(" {} conversations, none readable just now", d.sessions), + format!(" none of {} readable", d.sessions), + " unreadable".to_string(), + ] + } else { + [ + format!(" from {} of {} conversations", d.counted, d.sessions), + format!(" {} of {} readable", d.counted, d.sessions), + format!(" {}/{}", d.counted, d.sessions), + ] + }; + let fits = forms + .iter() + .find(|form| form.chars().count() as i64 <= room) + .unwrap_or(&forms[2]); + fits.clone() +} + +/// What this machine recorded: conversations, steps and prompts. +/// +/// Steps are the one number here that can go missing. A conversation the +/// running agent has locked is unreadable rather than empty, so a partial +/// sum says how partial it is and a sum of nothing says nothing - a zero +/// would read as an idle agent, which is the opposite of the truth. +fn antigravity_activity(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── ACTIVITY ── ".into()), + ( + p.dim.as_str(), + format!( + "local · {}", + if d.last > 0.0 { + format!("last {} ago", ago(d.last)) + } else { + "never run here".to_string() + } + ), + ), + ], + w - 1, + )]; + let steps = if d.counted == 0 && d.sessions > 0 { + "—".to_string() + } else { + format!("{}", d.steps as i64) + }; + let cells = [ + ("conversations", format!("{}", d.sessions), p.txt.as_str()), + ("agent steps", steps, p.agent.as_str()), + ("prompts", format!("{}", d.prompts), p.txt.as_str()), + ]; + let label_w = cells.iter().map(|c| c.0.len()).max().unwrap_or(0); + for (label, value, colour) in &cells { + let lead = format!(" {} ", tc::pad(label, label_w)); + let mut line = vec![(p.dim.as_str(), lead.clone()), (*colour, value.clone())]; + // Only the step count can be short of the truth, and it says so + // beside the number rather than in a footnote further down. + let note = match *label { + "agent steps" => steps_note( + d, + w as i64 - 1 - lead.chars().count() as i64 - value.chars().count() as i64, + ), + _ => String::new(), + }; + if !note.is_empty() { + line.push((p.warn.as_str(), note)); + } + rows.push(tc::seg(&line, w - 1)); + } + rows.push(String::new()); + rows +} + +fn antigravity_body(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let mut rows = antigravity_quota_rows(&d.quota, d.quota_from, w, p); + if d.live.is_none() { + rows.extend( + wrap_text(&tier_note_said(d.tier_why, &d.tier_said), w.saturating_sub(4).max(20)) + .into_iter() + .map(|line| tc::seg(&[(p.warn.as_str(), format!(" {}", line))], w - 1)), + ); + rows.push(String::new()); + } + rows.extend(antigravity_activity(d, w, p)); + // The absence is only worth explaining while it is one. With the quota + // drawn above, a paragraph about why there is no quota contradicts the + // screen. + // + // When there is no quota, the sentence carries the step that failed + // rather than the shape of the problem. "It comes from the language + // server while Antigravity is running" is true and was all this said, + // and it sends a reader to open an app when the actual fault can be a + // session signed out, a token an hour old, or a setting turned off - + // none of which opening the app on its own would settle. + let absent = if d.quota_why.is_empty() { + "No tokens are recorded locally, and no quota either: it comes \ + from the language server while Antigravity is running, so start \ + it and this fills in." + .to_string() + } else { + format!( + "No tokens are recorded locally, and no quota either: {}", + d.quota_why + ) + }; + rows.extend(no_local( + if d.quota.is_empty() { + absent.as_str() + } else { + "No per-token usage is recorded locally - the conversations and \ + steps above are what there is." + }, + "", + w, + p, + )); + rows +} + +/// The whole tab: what is left of the limits, what this machine did, and +/// which subscription the percentages are percentages of. +pub fn tab(d: &Data, w: usize, _h: usize, _cfg: &Config, p: &Palette) -> Vec<String> { + add_section(antigravity_body(d, w, p), antigravity_plan_rows(d, w, p)) +} + +#[cfg(test)] +mod tests { + + #[test] + fn the_note_says_which_step_failed_not_that_something_did() { + // "No quota" covered four situations wanting different things from + // the reader: never signed in, signed out since, an hour-old token, + // and a Google that said no. Only one of them is fixed by opening + // the app, so the note carries whichever actually happened. + let with = |why: &str| Data { + live: Some(serde_json::json!({"currentTier": {"id": "free"}})), + quota: Vec::new(), + quota_why: why.to_string(), + ..Data::default() + }; + + let d = with("Antigravity's token expired 2h ago - it refreshes them itself"); + assert!(lanes(&d).is_empty(), "no groups, no lanes"); + let note = why_no_lane(&d); + assert!(note.starts_with("no quota · "), "{}", note); + assert!(note.contains("expired 2h ago"), "the step that failed was dropped: {}", note); + + // Off by configuration is a reason too, and names the key. + let note = why_no_lane(&with( + "asking Google is off - set usage.antigravity_remote to true", + )); + assert!(note.contains("usage.antigravity_remote"), "{}", note); + + // A tier that cannot be read still wins: it is the older problem and + // opening the app is what fixes it. + let lapsed = Data { + live: None, + tier_why: Missing::Expired(3600.0), + quota_why: "Google refused the Antigravity token".into(), + ..Data::default() + }; + let note = why_no_lane(&lapsed); + assert!(note.contains("expired"), "tier reason lost: {}", note); + assert!(!note.contains("Google refused"), "two reasons at once: {}", note); + } + + #[test] + fn the_remote_ask_refuses_before_it_leaves_the_machine() { + // Turned off, and there is no request to make - the reason names the + // key rather than blaming the network. + let off = remote_token(false).unwrap_err(); + assert!(off.contains("usage.antigravity_remote"), "{}", off); + // It may name Google - that is where the request would have gone - + // but it must not report a refusal or a silence that never happened. + for blame in ["refused", "did not answer", "no quota groups"] { + assert!(!off.contains(blame), "blamed the server for a setting: {}", off); + } + } + use super::*; + + /// A conversation store built here rather than found on this machine: + /// the schema is the whole assertion, and a real one belongs to a live + /// agent. + fn temp_db(tag: &str, steps: usize, table: &str) -> String { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!( + "toys-antigravity-{}-{}-{}.db", + tag, + std::process::id(), + unique + )); + let name = path.to_string_lossy().to_string(); + let con = Connection::open(&name).expect("a temp database"); + con.execute(&format!("create table {} (id integer primary key)", table), []) + .expect("the schema"); + for i in 0..steps { + con.execute(&format!("insert into {} (id) values (?1)", table), [i as i64]) + .expect("a row"); + } + drop(con); + name + } + + fn groups(fractions: &[(&str, &str, f64)]) -> Vec<serde_json::Value> { + let mut out: Vec<serde_json::Value> = Vec::new(); + for (family, window, left) in fractions { + let bucket = serde_json::json!({ + "window": window, + "remainingFraction": left, + "resetTime": "2099-01-01T00:00:00Z", + }); + match out + .iter_mut() + .find(|g| text(g, "displayName") == *family) + { + Some(g) => g["buckets"].as_array_mut().unwrap().push(bucket), + None => out.push(serde_json::json!({ + "displayName": family, + "buckets": [bucket], + })), + } + } + out + } + + #[test] + fn the_steps_table_is_counted_from_a_database_we_built() { + let path = temp_db("counts", 7, "steps"); + assert_eq!(conversation_steps(&path), Some(7.0)); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn a_database_that_is_not_there_is_unknown_rather_than_created() { + // The point of opening read-only: a missing path must not become an + // empty database that then reports zero steps for ever after. + let missing = std::env::temp_dir().join(format!( + "toys-antigravity-absent-{}.db", + std::process::id() + )); + let name = missing.to_string_lossy().to_string(); + assert_eq!(conversation_steps(&name), None); + assert!(!missing.exists()); + } + + #[test] + fn a_store_without_a_steps_table_counts_as_unreadable_not_as_zero() { + let path = temp_db("noschema", 3, "notes"); + assert_eq!(conversation_steps(&path), None); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn a_conversation_that_would_not_open_leaves_the_step_count_unknown() { + let p = palette(); + let d = Data { + sessions: 3, + counted: 0, + steps: 0.0, + last: now() - 60.0, + ..Data::default() + }; + let shown = antigravity_activity(&d, 90, &p).join(" "); + assert!(shown.contains("—"), "an unknown count is not a zero"); + assert!(shown.contains("3 conversations, none readable just now")); + } + + #[test] + fn the_caveat_shortens_rather_than_being_cut_in_half() { + let d = Data { + sessions: 9, + counted: 7, + ..Data::default() + }; + assert_eq!(steps_note(&d, 80), " from 7 of 9 conversations"); + assert_eq!(steps_note(&d, 20), " 7 of 9 readable"); + assert_eq!(steps_note(&d, 8), " 7/9"); + // Narrower than any form: the shortest still says 7 of 9, which is + // the part that must survive. + assert_eq!(steps_note(&d, 1), " 7/9"); + let whole = Data { + sessions: 9, + counted: 9, + ..Data::default() + }; + assert_eq!(steps_note(&whole, 4), ""); + // Nothing found is not something unreadable. + assert_eq!(steps_note(&Data::default(), 80), ""); + } + + #[test] + fn a_narrow_pane_keeps_the_caveat_whole() { + let p = palette(); + let d = Data { + sessions: 9, + counted: 7, + steps: 1234.0, + ..Data::default() + }; + let shown = antigravity_activity(&d, 44, &p).join(" "); + assert!(!shown.contains("conversa "), "a clipped word is a broken one"); + assert!(shown.contains("7 of 9 readable")); + } + + #[test] + fn a_partial_step_count_says_how_partial_it_is() { + let p = palette(); + let d = Data { + sessions: 9, + counted: 7, + steps: 1234.0, + ..Data::default() + }; + let shown = antigravity_activity(&d, 90, &p).join(" "); + assert!(shown.contains("1234")); + assert!(shown.contains("from 7 of 9 conversations")); + assert!(shown.contains("never run here")); + } + + #[test] + fn a_complete_step_count_carries_no_caveat() { + let p = palette(); + let d = Data { + sessions: 2, + counted: 2, + steps: 40.0, + prompts: 5, + ..Data::default() + }; + let shown = antigravity_activity(&d, 90, &p).join(" "); + assert!(shown.contains("40")); + assert!(!shown.contains("of 2 conversations")); + } + + #[test] + fn every_bucket_of_every_group_becomes_one_lane() { + let d = Data { + quota: groups(&[ + ("Gemini 3 Pro", "weekly", 0.25), + ("Gemini 3 Pro", "5h", 1.0), + ("Claude Sonnet 4.5", "weekly", 0.5), + ]), + ..Data::default() + }; + let found = lanes(&d); + assert_eq!(found.len(), 3); + assert_eq!(found[0].label, "gemini weekly"); + assert!((found[0].pct - 75.0).abs() < 1e-9); + assert_eq!(found[0].window_secs, Some(7.0 * 86400.0)); + assert_eq!(found[1].label, "gemini 5h"); + assert_eq!(found[1].pct, 0.0); + assert_eq!(found[2].label, "claude weekly"); + assert!(found.iter().all(|l| !l.stale)); + assert!(found.iter().all(|l| l.reset.is_some())); + } + + #[test] + fn a_bucket_with_no_fraction_is_left_out_rather_than_read_as_full() { + let d = Data { + quota: vec![serde_json::json!({ + "displayName": "Gemini 3 Pro", + "buckets": [ + {"window": "weekly"}, + {"window": "5h", "remainingFraction": 0.9}, + ], + })], + ..Data::default() + }; + let found = lanes(&d); + assert_eq!(found.len(), 1); + assert_eq!(found[0].label, "gemini 5h"); + } + + #[test] + fn a_group_names_itself_once_and_its_windows_underneath() { + let p = palette(); + let rows = antigravity_quota_rows( + &groups(&[ + ("Gemini 3 Pro", "weekly", 0.25), + ("Gemini 3 Pro", "5h", 0.996), + ("Claude Sonnet 4.5", "weekly", 1.0), + ]), + From::Local, + 96, + &p, + ); + let shown = rows.join("\n"); + assert_eq!(shown.matches("Gemini 3 Pro").count(), 1); + assert!(shown.contains("Claude Sonnet 4.5")); + assert!(shown.contains("QUOTA")); + // Spent, not remaining: a quarter left is three quarters gone. + assert!(shown.contains("75%")); + // A real small number and no number at all have to be tellable apart. + assert!(shown.contains("0.40%")); + } + + #[test] + fn a_group_with_nothing_measurable_in_it_is_skipped() { + let p = palette(); + let rows = antigravity_quota_rows( + &[serde_json::json!({"displayName": "GPT", "buckets": []})], + From::Local, + 96, + &p, + ); + // The header and its blank line, and no group heading between them. + assert_eq!(rows.len(), 2); + assert!(!rows[0].contains("GPT")); + } + + #[test] + fn the_source_note_shortens_before_it_can_clip() { + let p = palette(); + let quota = groups(&[("Gemini 3 Pro", "weekly", 0.5)]); + let wide = antigravity_quota_rows("a, From::Local, 120, &p)[0].clone(); + let narrow = antigravity_quota_rows("a, From::Local, 60, &p)[0].clone(); + let tight = antigravity_quota_rows("a, From::Local, 30, &p)[0].clone(); + assert!(wide.contains("from the local language server")); + assert!(narrow.contains("from the local server")); + assert!(tight.contains("local")); + assert!(!tight.contains("from the local server")); + } + + #[test] + fn no_quota_is_explained_and_a_quota_is_not_contradicted() { + let p = palette(); + // A reason has to be named: the tab says which of the three it is, + // so a fixture that leaves it at "nothing is missing" is asking for + // a sentence there is no cause to print. + let empty = antigravity_body( + &Data { + tier_why: Missing::NoAnswer, + ..Data::default() + }, + 90, + &p, + ) + .join(" "); + assert!(empty.contains("no quota either")); + assert!(empty.contains("no tier")); + let with = antigravity_body( + &Data { + quota: groups(&[("Gemini 3 Pro", "weekly", 0.5)]), + live: Some(serde_json::json!({"currentTier": {"id": "free-tier"}})), + ..Data::default() + }, + 90, + &p, + ) + .join(" "); + assert!(with.contains("No per-token usage is recorded locally")); + assert!(!with.contains("no quota either")); + assert!(!with.contains("no tier")); + } + + #[test] + fn each_missing_tier_asks_for_something_different() { + // The line this replaced offered "expired or the call failed" for + // all three, which is two causes, no commitment and nothing to do. + let signed_out = tier_note(Missing::NotSignedIn); + let lapsed = tier_note(Missing::Expired(3.0 * 3600.0)); + let silent = tier_note(Missing::NoAnswer); + for note in [&signed_out, &lapsed, &silent] { + assert!(note.starts_with("no tier · "), "{:?}", note); + assert!(note.trim_end().ends_with('.'), "not a sentence: {:?}", note); + } + assert!(signed_out.contains("not signed in"), "{}", signed_out); + assert!(lapsed.contains("3h"), "the age is the useful part: {}", lapsed); + assert!(silent.contains("Google"), "{}", silent); + // and each is a different sentence, which is the whole point + assert_ne!(signed_out, lapsed); + assert_ne!(lapsed, silent); + assert_ne!(signed_out, silent); + // nothing missing says nothing + assert!(tier_note(Missing::Nothing).is_empty()); + } + + #[test] + fn the_subscription_states_both_tiers_when_they_disagree() { + let p = palette(); + let d = Data { + live: Some(serde_json::json!({ + "currentTier": {"id": "free-tier", "name": "Gemini Code Assist for individuals"}, + "paidTier": {"name": "Google AI Ultra"}, + "cloudaicompanionProject": "example-project", + })), + auth: "oauth-personal".into(), + ..Data::default() + }; + let shown = antigravity_plan_rows(&d, 90, &p).join(" "); + assert!(shown.contains("Gemini Code Assist for individuals")); + assert!(shown.contains("free-tier")); + assert!(shown.contains("Google AI Ultra")); + assert!(shown.contains("example-project")); + assert!(shown.contains("oauth-personal")); + } + + #[test] + fn no_tier_at_all_draws_no_subscription_block() { + let p = palette(); + assert!(antigravity_plan_rows(&Data::default(), 90, &p).is_empty()); + let hollow = Data { + live: Some(serde_json::json!({"currentTier": {}, "paidTier": {}})), + ..Data::default() + }; + assert!(antigravity_plan_rows(&hollow, 90, &p).is_empty()); + } + + #[test] + fn only_the_executable_counts_as_the_language_server() { + assert!(is_language_server("/opt/example/bin/antigravity --serve")); + assert!(is_language_server("agy chat")); + assert!(is_language_server("/opt/example/language_server_linux_x64")); + // A word boundary, so the hyphenated CLI still matches. + assert!(is_language_server("/opt/example/antigravity-cli")); + // Named in an argument rather than run: not this process. + assert!(!is_language_server("grep -rn antigravity /tmp/notes")); + assert!(!is_language_server("vim antigravity.md")); + assert!(!is_language_server("/opt/example/bin/antigravityish")); + assert!(!is_language_server("/opt/example/bin/agy_helper")); + assert!(!is_language_server("")); + } + + #[test] + fn only_listening_sockets_we_own_yield_a_port() { + // An invented /proc/net/tcp: two listeners and one established + // connection, of which one listener is ours. + let table = "\ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:9C40 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 4242 + 1: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 9999 + 2: 0100007F:9C41 0100007F:D431 01 00000000:00000000 00:00000000 00000000 1000 0 4243 +"; + let mine: HashSet<String> = ["4242", "4243"].iter().map(|s| s.to_string()).collect(); + assert_eq!(listening_ports(table, &mine), vec![40000]); + assert!(listening_ports(table, &HashSet::new()).is_empty()); + } + + #[test] + fn every_section_draws_at_every_pane_width() { + // Two of the three widths here subtract a constant from the pane + // before handing the remainder to a bar, and a pane narrower than + // the constant is exactly how a widget ends up on the floor. + let p = palette(); + let cfg = Config::default(); + let d = Data { + quota_from: From::Local, + quota_why: String::new(), + quota: groups(&[ + ("Gemini 3 Pro", "weekly", 0.25), + ("Gemini 3 Pro", "5h", 0.996), + ]), + live: Some(serde_json::json!({ + "currentTier": {"id": "free-tier", "name": "Gemini Code Assist for individuals"}, + "paidTier": {"name": "Google AI Ultra"}, + })), + auth: "oauth-personal".into(), + sessions: 9, + counted: 7, + steps: 1234.0, + prompts: 42, + last: now() - 3600.0, + tier_why: Missing::Nothing, + tier_said: String::new(), + }; + for w in [20usize, 40, 80, 200] { + let plain = tab(&d, w, 40, &cfg, &p).join("\n"); + for want in ["QUOTA", "ACTIVITY", "SUBSCRIPTION"] { + assert!(plain.contains(want), "{} missing at width {}", want, w); + } + } + } + + #[test] + fn a_window_the_server_does_not_name_has_no_pace() { + assert_eq!(window_secs("weekly"), Some(7.0 * 86400.0)); + assert_eq!(window_secs("5h"), Some(5.0 * 3600.0)); + assert_eq!(window_secs("monthly"), None); + assert_eq!(window_secs("?"), None); + } +} diff --git a/widgets/src/bin/usage/claude.rs b/widgets/src/bin/usage/claude.rs new file mode 100644 index 0000000..9c80e4c --- /dev/null +++ b/widgets/src/bin/usage/claude.rs @@ -0,0 +1,1451 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Claude Code: its stats cache, its transcripts, and what is left of the +//! account's limits. +//! +//! The money comes from the transcripts rather than the cache. The cache +//! has one total per model per day, and input, output and the two cache +//! durations differ in price by up to fifty times, so a total cannot be +//! costed. + +use std::collections::HashMap; + +use chrono::{Datelike, Duration as Days, Local, NaiveDate, TimeZone}; +use toys_core as tc; + +use crate::shared::*; +use crate::*; + +/// Newest transcripts to sample for a rate. +const RATE_FILES: usize = 3; +/// Seconds; below this the timestamps are not a turn. +const MIN_GAP: f64 = 1.0; + +/// What Claude Code has recorded, plus what is left of the limits. +#[derive(Clone, Default)] +pub struct Data { + ok: bool, + why: String, + stats: serde_json::Value, + /// The live or cached rate-limit reading, which is account-wide rather + /// than about this machine. + quota: Option<serde_json::Value>, + quota_live: bool, + quota_at: f64, + /// Why the last attempt did not answer, when it did not. Empty while it + /// is answering. + quota_why: String, + quota_plan: String, + profile: Option<serde_json::Value>, + /// Output tokens per second, sorted, and how many transcripts it came + /// from. + rates: Vec<f64>, + sampled: usize, + /// day -> model -> tokens by priced kind. + daily: HashMap<String, HashMap<String, Tokens>>, +} + +/// The OAuth token Claude Code already holds. +/// +/// It goes only to Anthropic, is never printed, and an expired one is not +/// used at all: the refresh token sits beside it, but spending it would +/// race Claude Code's own credential handling for a number that has a local +/// cache anyway. +pub fn claude_token() -> Option<(String, String)> { + let creds = read_json(&under_home(".claude/.credentials.json"))?; + let o = &creds["claudeAiOauth"]; + let tok = text(o, "accessToken"); + if tok.is_empty() || num(o, "expiresAt") / 1000.0 <= now() { + return None; + } + Some((tok, text(o, "subscriptionType"))) +} + +pub fn claude_get(url: &str, tok: &str) -> Option<serde_json::Value> { + claude_try(url, tok).ok() +} + +/// The same request, keeping why it failed. +/// +/// `.ok()?` threw that away, so the tab could say a reading was old and +/// never why. The server had been answering "too many requests" for an hour +/// while the screen said "cached" and a credential with three hours left on +/// it got the blame. +fn claude_try(url: &str, tok: &str) -> Result<serde_json::Value, String> { + let body = tc::get( + url, + &[ + ("Authorization", &format!("Bearer {}", tok)), + ("User-Agent", "terminal-toys"), + ], + 20, + ) + .map_err(|said| refusal(&said))?; + serde_json::from_str(&body).map_err(|e| format!("unreadable answer: {}", e)) +} + +/// What Claude Code last fetched, for when the live call cannot run. +/// +/// It is a cache with a timestamp, so it is shown with its age - and a +/// window whose reset has already gone by is said to have passed rather +/// than counted down to, because a stale five-hour window describes a +/// period that has ended. +/// How long Claude Code trusts its own cache, taken from its own reader: +/// `let r = Date.now() - n.data.fetchedAtMs; if (r < 0 || r > zNo) return null` +/// with `zNo = 3600000`. Identical in 2.1.233 and 2.1.243, so it is the +/// contract rather than a detail of one build. +const CLAUDE_CACHE_TTL: f64 = 3600.0; + +/// How often this endpoint is actually asked, where the other agents use +/// the shared two minutes. +/// +/// api.anthropic.com/api/oauth/usage rate-limits this token, and the limit +/// is shared with everything else holding it: every running copy of this +/// widget, and Claude Code itself. Three copies on the two-minute hold came +/// to a call every forty seconds between them and the endpoint answered 429 +/// - consistently with three running, intermittently with two, and 200 with +/// none, which is as clear as that gets. +/// +/// Five minutes because that is the cadence Claude Code chose for the same +/// data: its own refresh is throttled to BNo = 300000. There is no argument +/// for asking more often than the client that owns the number. +const CLAUDE_LIVE_TTL: f64 = 300.0; + +/// How old a reading may be and still be shown as current. +/// +/// Half an hour. A reading is already up to five minutes behind the server +/// in normal running - a live one is held for CLAUDE_LIVE_TTL - and a quota +/// window that turns over weekly does not move enough in half an hour to +/// mislead anyone. It is also where the backoff stops doubling, so the +/// screen starts saying "old" at the same moment the widget has given up +/// trying quickly. +/// +/// Marking by where a reading came from rather than by how old it is put a +/// star on a figure thirty-five seconds old while a live one four minutes +/// old carried none - the fresher of the two flagged as the doubtful one. +/// The mark is worth having when it means "older than this widget's own +/// cycle"; it is noise when it means "another process fetched it". +const CLAUDE_FRESH_FOR: f64 = 1800.0; + +/// True when the reading is old enough to be worth flagging, whatever its +/// source. `quota_at` is when it was taken, not when it was read. +fn reading_is_old(taken_at: f64) -> bool { + now() - taken_at > CLAUDE_FRESH_FOR +} + +/// Where our own last good reading lives. +/// +/// Claude Code keeps one in ~/.claude.json and expires it after an hour. +/// This widget was reading that file with no age check at all, and on this +/// machine it had been showing a reading from nine days after Claude Code +/// stopped refreshing it - a percentage its own author discards, presented +/// as today's. +/// +/// So we keep our own, written whenever the live call answers. On a machine +/// where Claude is in use that is most refreshes, which makes it minutes old +/// rather than days, and it does not depend on another program's cache still +/// being maintained. CodexBar does the same and for the same reason: its +/// Claude sources are the API and the CLI, never that file, with its own +/// snapshot shown by capture age when they all fail. +fn snapshot_path() -> String { + let base = std::env::var("XDG_STATE_HOME").unwrap_or_else(|_| { + format!("{}/.local/state", std::env::var("HOME").unwrap_or_default()) + }); + format!("{}/terminal-toys/claude-usage.json", base) +} + +/// The account the reading belongs to, so switching accounts does not show +/// the old one's figures. Claude Code guards its cache the same way; we +/// borrow its marker because the usage response carries no account of its +/// own. Absent on a machine whose Claude Code has never written the key, and +/// then the guard is simply not applied - a missing marker is not a mismatch. +fn account_marker() -> Option<String> { + let config = read_json(&under_home(".claude.json"))?; + let uuid = text(&config["cachedUsageUtilization"], "accountUuid"); + (!uuid.is_empty()).then_some(uuid) +} + +fn save_snapshot(utilization: &serde_json::Value) { + save_snapshot_at(&snapshot_path(), utilization) +} + +/// The write, against a named path so it can be tested without reaching for +/// the real one or rewriting the environment out from under other tests. +fn save_snapshot_at(path: &str, utilization: &serde_json::Value) { + if let Some(dir) = std::path::Path::new(path).parent() { + let _ = std::fs::create_dir_all(dir); + } + let mut body = serde_json::json!({ + "fetchedAtMs": now() * 1000.0, + "utilization": utilization, + }); + if let Some(uuid) = account_marker() { + body["accountUuid"] = serde_json::json!(uuid); + } + let _ = std::fs::write(path, body.to_string()); +} + +fn read_snapshot_at(path: &str) -> Option<(serde_json::Value, f64)> { + let saved = read_json(path)?; + let u = saved["utilization"].clone(); + if u.is_null() { + return None; + } + let held = text(&saved, "accountUuid"); + if let (false, Some(now_uuid)) = (held.is_empty(), account_marker()) { + if held != now_uuid { + return None; + } + } + Some((u, num(&saved, "fetchedAtMs") / 1000.0)) +} + +fn claude_code_cache_at(path: &str) -> Option<(serde_json::Value, f64)> { + let config = read_json(path)?; + let c = &config["cachedUsageUtilization"]; + let u = c["utilization"].clone(); + if u.is_null() { + return None; + } + let at = num(c, "fetchedAtMs") / 1000.0; + let age = now() - at; + if !(0.0..=CLAUDE_CACHE_TTL).contains(&age) { + return None; + } + Some((u, at)) +} + +/// The best reading we have that is not live: ours if we have one, Claude +/// Code's while it is still within the hour it trusts it for, whichever was +/// taken more recently. +pub fn claude_stale() -> Option<(serde_json::Value, f64)> { + stale_from(&snapshot_path(), &under_home(".claude.json")) +} + +/// The whole fallback against two named files, so a test can put a fossil +/// and a fresh reading on disk and check which one comes back. +fn stale_from(ours: &str, theirs: &str) -> Option<(serde_json::Value, f64)> { + fresher(read_snapshot_at(ours), claude_code_cache_at(theirs)) +} + +/// Whichever reading was taken later, and neither being present is not an +/// error - it is a machine that has never had a live one. A tie goes to the +/// first, which is ours: the one whose provenance we know. +fn fresher<T>(ours: Option<(T, f64)>, theirs: Option<(T, f64)>) -> Option<(T, f64)> { + match (ours, theirs) { + (Some(a), Some(b)) => Some(if a.1 >= b.1 { a } else { b }), + (a, b) => a.or(b), + } +} + +/// Token counts from one transcript usage block, by priced kind. +/// +/// A block's top-level numbers can all be zero while its `iterations` carry +/// the real figures, so the iterations win where they exist. Cache writes +/// are split by duration because they are priced differently, and the flat +/// cache_creation_input_tokens is only used when that split is absent. +pub fn usage_kinds(u: &serde_json::Value) -> Tokens { + let mut out = empty_tokens(); + let empty = vec![u.clone()]; + let blocks: Vec<serde_json::Value> = match u["iterations"].as_array() { + Some(list) if !list.is_empty() => list.clone(), + _ => empty, + }; + for x in &blocks { + *out.get_mut("input").unwrap() += num(x, "input_tokens"); + *out.get_mut("output").unwrap() += num(x, "output_tokens"); + *out.get_mut("cache_read").unwrap() += num(x, "cache_read_input_tokens"); + let split = &x["cache_creation"]; + if split.is_object() { + *out.get_mut("cache_write").unwrap() += num(split, "ephemeral_5m_input_tokens"); + *out.get_mut("cache_write_1h").unwrap() += num(split, "ephemeral_1h_input_tokens"); + } else { + *out.get_mut("cache_write").unwrap() += num(x, "cache_creation_input_tokens"); + } + } + out +} + +/// Per-record token counts from one transcript, keyed by record uuid. +/// +/// Keyed rather than summed because the same message appears in more than +/// one file: resuming or forking a session replays its history into the new +/// transcript, and subagent turns are written twice over. Left raw that +/// inflated one model by 29% against Claude Code's own totals. +/// +/// Cached on (mtime, size): a finished transcript never changes, so each is +/// parsed once. +pub fn scan_transcript( + caches: &mut Caches, + path: &str, +) -> HashMap<String, (String, String, Tokens)> { + let Ok(meta) = std::fs::metadata(path) else { + return HashMap::new(); + }; + use std::os::unix::fs::MetadataExt; + let key = (meta.mtime() as u64, meta.size()); + if let Some((had, records)) = caches.transcripts.get(path) { + if *had == key { + return records.clone(); + } + } + let mut records = HashMap::new(); + let Ok(body) = std::fs::read_to_string(path) else { + return records; + }; + for line in body.lines() { + if !line.contains("\"usage\"") { + continue; + } + let Ok(r) = serde_json::from_str::<serde_json::Value>(line) else { + continue; + }; + let msg = &r["message"]; + let u = &msg["usage"]; + let (model, uid, stamp) = (text(msg, "model"), text(&r, "uuid"), text(&r, "timestamp")); + if u.is_null() || model.is_empty() || uid.is_empty() || stamp.is_empty() { + continue; + } + let Some(when) = iso_epoch(&stamp) else { + continue; + }; + let got = usage_kinds(u); + if total_tokens(&got) <= 0.0 { + continue; + } + let day = Local + .timestamp_opt(when as i64, 0) + .single() + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + records.insert(uid, (day, model, got)); + } + caches + .transcripts + .insert(path.to_string(), (key, records.clone())); + records +} + +/// Every transcript's per-day, per-model tokens, de-duplicated. +/// +/// stats-cache.json has dailyModelTokens, but only one total per model per +/// day - and input, output and the two cache kinds differ in price by up to +/// fifty times, so a total cannot be costed. The transcripts carry the +/// split, which is why the money comes from here and not from the cache. +pub fn claude_daily(caches: &mut Caches) -> HashMap<String, HashMap<String, Tokens>> { + let mut files = Vec::new(); + // Recursive on purpose: subagent transcripts live a further two levels + // down, and that is where most of the smaller models actually run. + walk(&under_home(".claude/projects"), ".jsonl", &mut files); + let mut seen: HashMap<String, (String, String, Tokens)> = HashMap::new(); + for path in &files { + seen.extend(scan_transcript(caches, path)); + } + let mut merged: HashMap<String, HashMap<String, Tokens>> = HashMap::new(); + for (day, model, tokens) in seen.into_values() { + let bucket = merged + .entry(day) + .or_default() + .entry(model) + .or_insert_with(empty_tokens); + for kind in RATE_KINDS { + *bucket.get_mut(*kind).unwrap() += tokens.get(*kind).copied().unwrap_or(0.0); + } + } + merged +} + +/// Per-model token totals over the last N days (1 = today only). +pub fn window_models( + daily: &HashMap<String, HashMap<String, Tokens>>, + days: i64, +) -> Vec<(String, Tokens)> { + let first = (Local::now().date_naive() - Days::days(days - 1)) + .format("%Y-%m-%d") + .to_string(); + let mut out: HashMap<String, Tokens> = HashMap::new(); + for (day, models) in daily { + if *day < first { + continue; + } + for (model, tokens) in models { + let bucket = out.entry(model.clone()).or_insert_with(empty_tokens); + for kind in RATE_KINDS { + *bucket.get_mut(*kind).unwrap() += tokens.get(*kind).copied().unwrap_or(0.0); + } + } + } + let mut list: Vec<(String, Tokens)> = out.into_iter().collect(); + list.sort_by(|a, b| a.0.cmp(&b.0)); + list +} + +/// Output tokens per second, from the newest transcripts. +/// +/// A turn is a `user` record followed by an `assistant` one, and the rate is +/// that assistant's output tokens over the gap between them. Measuring from +/// any previous record instead inflates it wildly - two assistant records +/// can be milliseconds apart while the second reports a whole turn's output. +/// +/// The median is what gets shown: it barely moves whichever way the outliers +/// are trimmed, which is the reason to trust it, while the maximum moves by +/// a factor of twenty on the same data, which is the reason not to show one. +pub fn claude_rates() -> (Vec<f64>, usize) { + let mut files = Vec::new(); + walk(&under_home(".claude/projects"), ".jsonl", &mut files); + let mut with_time: Vec<(u64, String)> = files + .into_iter() + .filter_map(|path| { + use std::os::unix::fs::MetadataExt; + let meta = std::fs::metadata(&path).ok()?; + Some((meta.mtime() as u64, path)) + }) + .collect(); + with_time.sort_by(|a, b| b.0.cmp(&a.0)); + let mut out: Vec<f64> = Vec::new(); + let mut sampled = 0usize; + for (_, path) in with_time.iter().take(RATE_FILES) { + sampled += 1; + let (mut prev, mut prev_type): (Option<f64>, String) = (None, String::new()); + for line in tail_lines(path, 4 * 1024 * 1024) { + if !line.contains("\"timestamp\"") { + continue; + } + let Ok(d) = serde_json::from_str::<serde_json::Value>(&line) else { + continue; + }; + let (stamp, typ) = (text(&d, "timestamp"), text(&d, "type")); + let at = iso_epoch(&stamp); + if typ == "assistant" + && at.is_some() + && prev.is_some() + && prev_type == "user" + && !d["isAbortedMidStream"].as_bool().unwrap_or(false) + { + let tok = num(&d["message"]["usage"], "output_tokens"); + if tok > 0.0 { + let gap = at.unwrap() - prev.unwrap(); + if (MIN_GAP..300.0).contains(&gap) { + out.push(tok / gap); + } + } + } + if let Some(at) = at { + prev = Some(at); + prev_type = typ; + } + } + } + out.sort_by(f64::total_cmp); + (out, sampled) +} + +pub fn read(caches: &mut Caches, _cfg: &Config) -> Data { + let mut claude = Data::default(); + // The reason rides along in the cached value, so it is held and shown + // for as long as the failure it describes rather than only on the frame + // the request happened to be made. + let live = cached(caches, "claude", CLAUDE_LIVE_TTL, || { + let Some((tok, plan)) = claude_token() else { + return Some(serde_json::json!({ "why": "no token - Claude Code has not signed in here" })); + }; + match claude_try("https://api.anthropic.com/api/oauth/usage", &tok) { + Ok(u) => Some(serde_json::json!({ "u": u, "at": now(), "plan": plan })), + Err(why) => Some(serde_json::json!({ "why": why })), + } + }); + // A cached entry carrying only a reason is a refusal, not a reading. + let live = match live { + Some(got) if !text(&got, "why").is_empty() => { + claude.quota_why = text(&got, "why"); + None + } + other => other, + }; + match live { + Some(got) => { + claude.quota = Some(got["u"].clone()); + claude.quota_live = true; + claude.quota_at = num(&got, "at"); + claude.quota_plan = text(&got, "plan"); + // Kept for the next refresh that cannot reach the endpoint. The + // reading is held for LIVE_TTL either way, so this writes about + // once every two minutes rather than on every frame. + save_snapshot(&got["u"]); + } + None => { + if let Some((u, at)) = claude_stale() { + claude.quota = Some(u); + claude.quota_live = false; + claude.quota_at = at; + } + } + } + claude.profile = cached(caches, "claude-plan", PLAN_TTL, || { + let (tok, plan) = claude_token()?; + let mut d = claude_get("https://api.anthropic.com/api/oauth/profile", &tok)?; + d["_plan"] = serde_json::Value::String(plan); + Some(d) + }); + if claude.profile.is_none() { + // The profile endpoint is richer, but the credentials file needs no + // network and is always there, so the section degrades to two true + // lines instead of vanishing. + if let Some(creds) = read_json(&under_home(".claude/.credentials.json")) { + let o = &creds["claudeAiOauth"]; + let plan = text(o, "subscriptionType"); + if !plan.is_empty() { + claude.profile = Some(serde_json::json!({ + "_plan": plan, + "_local": true, + "organization": { "rate_limit_tier": text(o, "rateLimitTier") }, + })); + } + } + } + match read_json(&under_home(".claude/stats-cache.json")) { + Some(stats) => { + claude.ok = true; + claude.stats = stats; + let (rates, sampled) = claude_rates(); + claude.rates = rates; + claude.sampled = sampled; + claude.daily = claude_daily(caches); + } + None => claude.why = "no stats cache".into(), + } + claude +} + +/// Where a Claude limit belongs in the list, shortest leash first. +/// +/// The server returns them in no order worth keeping. Read top to bottom +/// they should widen: the five-hour session is what stops you this +/// afternoon, the weekly total is what stops you this week, and a +/// model-scoped weekly limit stops only one model. +pub fn claude_lane_rank(limit: &serde_json::Value) -> usize { + if text(limit, "kind") == "session" { + return 0; + } + if text(&limit["scope"]["model"], "display_name").is_empty() { + 1 + } else { + 2 + } +} + +/// The scope note, shortened before it can push a reset off the line. +/// +/// "not this machine" is the point of the sentence, but it is also sixteen +/// characters, and seg() clips whatever runs past the pane. Losing the +/// clause leaves a shorter true line; losing the end of "resets in 15d" +/// leaves "resets in 1", which is a different and wrong number. +pub fn scope_phrase(w: usize, used: usize) -> &'static str { + let full = " · account-wide, not this machine "; + if used + full.len() <= w - 1 { + full + } else { + " · account-wide " + } +} + +/// The windows Claude Code's own /usage shows. +/// +/// Read from limits[], which is the server's own curated list: the rest of +/// the response carries a dozen null pools that /usage does not render +/// either. Each entry names itself, so a model-scoped weekly limit arrives +/// labelled without this having to know the name. +pub fn claude_quota(c: &Data, w: usize, p: &Palette) -> Vec<String> { + let Some(u) = c.quota.as_ref() else { + return Vec::new(); + }; + let mut lanes: Vec<&serde_json::Value> = u["limits"] + .as_array() + .into_iter() + .flatten() + .filter(|l| !l["percent"].is_null()) + .collect(); + if lanes.is_empty() { + return Vec::new(); + } + lanes.sort_by_key(|l| claude_lane_rank(l)); + let src = if c.quota_live { + "live".to_string() + } else if c.quota_why.is_empty() { + format!("cached {} ago", ago(c.quota_at)) + } else { + // Why, not just how old. "cached 4m ago" tells a reader the number + // is behind and leaves them to guess whether their login has + // lapsed; naming the refusal is the difference between closing a + // spare pane and going through a credential that was never wrong. + format!("cached {} ago · {}", ago(c.quota_at), c.quota_why) + }; + let hue = agent_hue("claude"); + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── QUOTA ── ".into()), + ( + if c.quota_live { p.ok.as_str() } else { p.warn.as_str() }, + src.clone(), + ), + ( + p.dim.as_str(), + scope_phrase(w, 13 + src.len() + c.quota_plan.len()).to_string(), + ), + (p.dim.as_str(), c.quota_plan.clone()), + ], + w - 1, + )]; + + let label_of = |l: &serde_json::Value| -> String { + let scope = text(&l["scope"]["model"], "display_name"); + let group = text(l, "group"); + let name = if !scope.is_empty() { + scope + } else if text(l, "kind") == "weekly_all" { + "overall".to_string() + } else if !group.is_empty() { + group.clone() + } else { + match text(l, "kind") { + s if s.is_empty() => "?".into(), + s => s, + } + }; + let window = match group.as_str() { + "session" => "5h", + "weekly" => "7d", + _ => "", + }; + format!("{} {}", name, window).trim().to_string() + }; + let texts: Vec<String> = lanes.iter().map(|l| label_of(l)).collect(); + let label_w = texts.iter().map(|t| t.chars().count()).max().unwrap_or(9).max(9); + for (l, label) in lanes.iter().zip(&texts) { + let pct = num(l, "percent"); + let used = (pct / 100.0).clamp(0.0, 1.0); + let reset = iso_epoch(&text(l, "resets_at")); + let when = match reset { + None => String::new(), + Some(ts) => { + let left = ts - now(); + if left > 0.0 { + format!("resets in {}", left_span(left)) + } else if c.quota_live { + "resetting".into() + } else { + "already reset".into() + } + } + }; + let sev = text(l, "severity").to_lowercase(); + let window = CLAUDE_WINDOW_SECS + .iter() + .find(|(g, _)| *g == text(l, "group")) + .map(|(_, s)| *s); + let cushion = lead(pct, window, reset); + let (pace_colour, pace_txt) = pace_cell(cushion, p); + // is_active marks the limit currently doing the binding - the one + // that will stop you first - so it is the one worth reading brightly. + let mut line: Vec<(String, String)> = vec![( + if l["is_active"].as_bool().unwrap_or(false) { + p.txt.clone() + } else { + p.dim.clone() + }, + format!(" {} ", tc::pad(label, label_w)), + )]; + line.extend(paced_bar( + used, + elapsed_of(window, reset), + w.saturating_sub(35 + label_w).max(8), + hue, + p, + )); + line.push((pct_colour(pct, hue, p), pct_text(pct))); + line.push((pace_colour, pace_txt)); + line.push(( + if sev.is_empty() || sev == "normal" { p.dim.clone() } else { p.bad.clone() }, + if sev.is_empty() || sev == "normal" { + format!(" {}", when) + } else { + format!(" {} · {}", sev, when) + }, + )); + let refs: Vec<(&str, String)> = line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + let extra = &u["extra_usage"]; + let spend = &u["spend"]; + if extra["is_enabled"].as_bool().unwrap_or(false) && !spend["limit"].is_null() { + let money = |m: &serde_json::Value| -> String { + format!( + "{:.2}", + num(m, "amount_minor") / 10f64.powf(m["exponent"].as_f64().unwrap_or(2.0)) + ) + }; + rows.push(tc::seg( + &[ + (p.dim.as_str(), " extra usage ".into()), + (p.txt.as_str(), money(&spend["used"])), + (p.dim.as_str(), " of ".into()), + (p.txt.as_str(), money(&spend["limit"])), + (p.dim.as_str(), format!(" {}", text(&spend["limit"], "currency"))), + (p.dim.as_str(), " monthly".into()), + ], + w - 1, + )); + } + rows.push(String::new()); + rows +} + +/// How far behind today the stats cache's own reckoning is. +/// +/// `room` is the columns actually left on the line, measured by the caller +/// rather than guessed from the pane width - the text before this varies, +/// so a width threshold clipped at some widths and not others. +pub fn stats_lag(stats: &serde_json::Value, room: i64) -> String { + let last = text(stats, "lastComputedDate"); + let Ok(when) = NaiveDate::parse_from_str(&last, "%Y-%m-%d") else { + return String::new(); + }; + let days = (Local::now().date_naive() - when).num_days(); + if days <= 0 { + return String::new(); + } + let month = MONTHS[when.month0() as usize]; + for candidate in [ + format!(" cache is {}d behind, to {} {}", days, month, when.day()), + format!(" cache {}d behind", days), + format!(" -{}d", days), + ] { + if candidate.len() as i64 <= room { + return candidate; + } + } + String::new() +} + +pub fn claude_plan_rows(prof: &serde_json::Value, w: usize, p: &Palette) -> Vec<String> { + let org = &prof["organization"]; + let mut pairs: Vec<(String, String)> = Vec::new(); + let created = text(org, "subscription_created_at"); + if let (Some(since), day) = (iso_epoch(&created), iso_day(&created)) { + if !day.is_empty() { + pairs.push(("member since".into(), format!("{} · {} ago", day, ago(since)))); + } + } + for (label, key) in [ + ("status", "subscription_status"), + ("rate limit tier", "rate_limit_tier"), + ] { + let value = text(org, key); + if !value.is_empty() { + pairs.push((label.into(), value)); + } + } + let billing = text(org, "billing_type"); + if !billing.is_empty() { + pairs.push(("billing".into(), billing.replace('_', " "))); + } + let headline = match text(prof, "_plan") { + s if !s.is_empty() => s, + _ => text(org, "organization_type"), + }; + plan_rows( + &headline, + &pairs, + w, + if prof["_local"].as_bool().unwrap_or(false) { + "from credentials" + } else { + "" + }, + None, + "", + p, + ) +} + +pub fn claude_metered(c: &Data, w: usize, cfg: &Config, p: &Palette) -> Vec<String> { + metered_rows( + &[ + ("today".to_string(), window_models(&c.daily, 1)), + ("30 days".to_string(), window_models(&c.daily, 30)), + ], + w, + "", + "claude", + "this machine", + "Counted from transcripts, which are written where the agent ran. \ + Claude used on another machine, or on claude.ai, is not in here.", + cfg, + p, + ) +} + +pub fn claude_tab(c: &Data, w: usize, p: &Palette) -> Vec<String> { + let mut rows = claude_quota(c, w, p); + if !c.ok { + rows.extend(no_local( + &format!("No stats cache yet ({}).", c.why), + run_hint("claude"), + w, + p, + )); + return rows; + } + let d = &c.stats; + let mu = &d["modelUsage"]; + let sum_over = |key: &str| -> f64 { + mu.as_object() + .into_iter() + .flatten() + .map(|(_, v)| num(v, key)) + .sum() + }; + let (in_tok, out_tok) = (sum_over("inputTokens"), sum_over("outputTokens")); + let (cache_r, cache_w) = ( + sum_over("cacheReadInputTokens"), + sum_over("cacheCreationInputTokens"), + ); + + // The calendar is computed first: its streaks and active-day counts + // belong in the summary above it, not only beside the calendar. + let mut totals: HashMap<NaiveDate, f64> = HashMap::new(); + for entry in d["dailyModelTokens"].as_array().into_iter().flatten() { + let day = text(entry, "date"); + let Ok(at) = NaiveDate::parse_from_str(&day, "%Y-%m-%d") else { + continue; + }; + let total: f64 = entry["tokensByModel"] + .as_object() + .into_iter() + .flatten() + .map(|(_, v)| v.as_f64().unwrap_or(0.0)) + .sum(); + totals.insert(at, total); + } + let peak = totals.values().cloned().fold(0.0f64, f64::max); + let cal = day_calendar(&totals, w, HEAT_STEPS, None, p); + + let fav = mu + .as_object() + .into_iter() + .flatten() + .max_by(|a, b| num(a.1, "outputTokens").total_cmp(&num(b.1, "outputTokens"))) + .map(|(k, _)| k.replace("claude-", "")) + .unwrap_or_else(|| "—".into()); + let all_tokens = in_tok + out_tok + cache_r + cache_w; + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── SUMMARY ── ".into()), + ( + p.dim.as_str(), + format!( + "all time · since {}", + text(d, "firstSessionDate").chars().take(10).collect::<String>() + ), + ), + ], + w - 1, + )); + let facts = cal.as_ref(); + let best_txt = facts + .and_then(|c| c.best) + .map(|b| format!("{} {}", MONTHS[b.month0() as usize], b.day())) + .unwrap_or_else(|| "—".into()); + let pairs: Vec<(&str, String, &str, &str, String, &str)> = vec![ + ( + "Favorite model", + fav, + p.agent.as_str(), + "Total tokens", + big_num(all_tokens), + p.agent.as_str(), + ), + ( + "Sessions", + format!("{}", num(d, "totalSessions") as i64), + p.txt.as_str(), + "Longest session", + span_ms(num(&d["longestSession"], "duration")), + p.txt.as_str(), + ), + ( + "Active days", + facts + .map(|c| format!("{}/{}", c.active, c.span)) + .unwrap_or_else(|| "—".into()), + p.txt.as_str(), + "Longest streak", + facts + .map(|c| format!("{} days", c.longest)) + .unwrap_or_else(|| "—".into()), + p.txt.as_str(), + ), + ( + "Most active day", + best_txt, + p.txt.as_str(), + "Current streak", + facts + .map(|c| format!("{} days", c.current)) + .unwrap_or_else(|| "—".into()), + if facts.is_some_and(|c| c.current > 0) { p.ok.as_str() } else { p.dim.as_str() }, + ), + ]; + let lw = pairs + .iter() + .map(|(a, _, _, c, _, _)| a.len().max(c.len())) + .max() + .unwrap_or(10); + let half = (w - 3) / 2; + let vw = half.saturating_sub(lw + 2).max(6); + for (a, b, bc, c, e, ec) in &pairs { + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ", tc::pad(a, lw))), + (bc, tc::pad(b, vw)), + (p.dim.as_str(), format!(" {} ", tc::pad(c, lw))), + (ec, tc::pad(e, vw)), + ], + w - 1, + )); + } + rows.push(tc::seg( + &[ + (p.dim.as_str(), " Input ".into()), + (p.txt.as_str(), big_num(in_tok)), + (p.dim.as_str(), " · Output ".into()), + (p.txt.as_str(), big_num(out_tok)), + (p.dim.as_str(), " · Cache read ".into()), + (p.txt.as_str(), big_num(cache_r)), + (p.dim.as_str(), " · Cache written ".into()), + (p.txt.as_str(), big_num(cache_w)), + ], + w - 1, + )); + + // Which model did the work. + rows.push(String::new()); + let mut ranked: Vec<(String, f64)> = mu + .as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.clone(), num(v, "outputTokens"))) + .filter(|(_, tok)| *tok > 0.0) + .collect(); + ranked.sort_by(|a, b| b.1.total_cmp(&a.1)); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── BY MODEL ── ".into()), + (p.dim.as_str(), "output tokens".into()), + ], + w - 1, + )); + if let Some((_, top)) = ranked.first() { + let top = top.max(1.0); + for (name, tok) in ranked.iter().take(5) { + let bar = tc::meter(tok / top, w.saturating_sub(34).max(6)); + let filled = bar.chars().filter(|c| *c == '█').count(); + rows.push(tc::seg( + &[ + ( + p.txt.as_str(), + format!(" {}", tc::pad(&name.replace("claude-", ""), 20)), + ), + (p.agent.as_str(), format!("{:>7} ", big_num(*tok))), + (p.agent.as_str(), bar.chars().take(filled).collect::<String>()), + (p.grid.as_str(), bar.chars().skip(filled).collect::<String>()), + ], + w - 1, + )); + } + } + + // Messages per day, straight from the file. + let daily: Vec<&serde_json::Value> = d["dailyActivity"].as_array().map(|a| a.iter().collect()).unwrap_or_default(); + if !daily.is_empty() { + rows.push(String::new()); + let counts: Vec<f64> = daily.iter().map(|x| num(x, "messageCount")).collect(); + let msg_peak = counts.iter().cloned().fold(0.0f64, f64::max).max(1.0); + // Both charts on this tab come from stats-cache.json, which Claude + // Code recomputes on its own schedule. Unlabelled, that gap reads as + // idle days rather than as days the cache has not caught up with. + let head = " ── MESSAGES / DAY ── "; + let tail = format!("{}d · peak {}", daily.len(), msg_peak as i64); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), head.into()), + (p.dim.as_str(), tail.clone()), + ( + p.warn.as_str(), + stats_lag(d, w as i64 - 1 - head.len() as i64 - tail.len() as i64), + ), + ], + w - 1, + )); + let avail = w.saturating_sub(3).max(10); + let widths = tc::spread(counts.len(), avail); + let mut cols: Vec<(f64, String)> = Vec::new(); + for (c, wide) in counts.iter().zip(&widths) { + cols.extend(std::iter::repeat_n((*c, p.agent.clone()), *wide)); + } + for line in tc::vbars(&cols, 3, 0.0) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + rows.push(tc::seg( + &[(tc::RST, " ".into()), (p.grid.as_str(), "─".repeat(cols.len()))], + w - 1, + )); + let left: String = text(daily[0], "date").chars().skip(5).collect(); + let right: String = text(daily[daily.len() - 1], "date").chars().skip(5).collect(); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", left)), + ( + p.dim.as_str(), + " ".repeat(cols.len().saturating_sub(left.len() + right.len()).max(1)), + ), + (p.dim.as_str(), right), + ], + w - 1, + )); + } + + // How fast it generates. + if !c.rates.is_empty() { + let med = c.rates[c.rates.len() / 2]; + let p90 = c.rates[((c.rates.len() as f64 * 0.9) as usize).min(c.rates.len() - 1)]; + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── OUTPUT RATE ── ".into()), + ( + p.dim.as_str(), + format!("{} turns across {} transcripts", c.rates.len(), c.sampled), + ), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " median ".into()), + (p.agent.as_str(), format!("{:.0}", med)), + (p.dim.as_str(), " tok/s p90 ".into()), + (p.txt.as_str(), format!("{:.0}", p90)), + (p.dim.as_str(), " request to response, tools included".into()), + ], + w - 1, + )); + } + + // Tokens per day, as a calendar. + if let Some(cal) = cal { + rows.push(String::new()); + let head = " ── TOKENS / DAY ── peak "; + let tail = format!( + "{} on {}", + big_num(peak), + cal.best + .map(|b| format!("{} {}", MONTHS[b.month0() as usize], b.day())) + .unwrap_or_else(|| "--".into()) + ); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── TOKENS / DAY ── ".into()), + (p.dim.as_str(), "peak ".into()), + (p.agent.as_str(), big_num(peak)), + ( + p.dim.as_str(), + format!( + " on {}", + cal.best + .map(|b| format!("{} {}", MONTHS[b.month0() as usize], b.day())) + .unwrap_or_else(|| "--".into()) + ), + ), + ( + p.warn.as_str(), + stats_lag(d, w as i64 - 1 - head.len() as i64 - tail.len() as i64), + ), + ], + w - 1, + )); + for line in &cal.rows { + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + let mut legend: Vec<(&str, String)> = vec![(p.dim.as_str(), " Less ".into())]; + let swatches: Vec<String> = HEAT_STEPS.iter().map(|(r, g, b)| tc::rgb(*r, *g, *b)).collect(); + for colour in &swatches { + legend.push((colour.as_str(), "█".into())); + } + legend.push((p.dim.as_str(), " More".into())); + rows.push(tc::seg(&legend, w - 1)); + } + rows +} + +/// Every quota Claude publishes, in the shape the summary screen compares. +/// +/// The same limits[] the tab renders, reduced to the four numbers that mean +/// the same thing across agents. Left in the server's own order, because +/// Claude's windows nest - the five-hour sits inside the weekly, which +/// contains the model-scoped limit in turn - and reading them widest-last +/// says more than reading them by percentage. +pub fn lanes(c: &Data) -> Vec<Lane> { + let Some(u) = c.quota.as_ref() else { + return Vec::new(); + }; + let mut found: Vec<&serde_json::Value> = u["limits"] + .as_array() + .into_iter() + .flatten() + .filter(|l| !l["percent"].is_null()) + .collect(); + found.sort_by_key(|l| claude_lane_rank(l)); + found + .into_iter() + .map(|l| { + let group = text(l, "group"); + let scope = text(&l["scope"]["model"], "display_name"); + let name = if !scope.is_empty() { + scope + } else if text(l, "kind") == "weekly_all" { + "overall".to_string() + } else if !group.is_empty() { + group.clone() + } else { + match text(l, "kind") { + s if s.is_empty() => "?".into(), + s => s, + } + }; + let window = match group.as_str() { + "session" => "5h", + "weekly" => "7d", + _ => "", + }; + // A cached reading can be old enough that the window it names has + // closed - this machine's was nine days back, naming windows that + // ended on the sixteenth. Claude's windows are fixed lengths, so + // the one we are in now is that one rolled forward, and a + // countdown to it is worth more than the blank a past date left. + // + // The percentage is not rolled with it. That belonged to the + // closed window and the current one's spend is unmeasured, which + // is why `projected` also suppresses the pace. + let secs = CLAUDE_WINDOW_SECS + .iter() + .find(|(g, _)| *g == group) + .map(|(_, s)| *s); + let rolled = match (iso_epoch(&text(l, "resets_at")), secs) { + (Some(at), Some(len)) if at <= now() && len > 0.0 => { + let skipped = ((now() - at) / len).floor() + 1.0; + (Some(at + skipped * len), true) + } + (at, _) => (at, false), + }; + Lane { + label: format!("{} {}", name, window).trim().to_string(), + pct: num(l, "percent"), + window_secs: CLAUDE_WINDOW_SECS + .iter() + .find(|(g, _)| *g == group) + .map(|(_, s)| *s), + reset: rolled.0, + stale: reading_is_old(c.quota_at), + projected: rolled.1, + apart: false, + } + }) + .collect() +} + +/// The whole tab: the quota, what the machine recorded, what it cost, and +/// which subscription the percentages are percentages of. +pub fn tab(c: &Data, w: usize, _h: usize, cfg: &Config, p: &Palette) -> Vec<String> { + let body = add_section(claude_tab(c, w, p), claude_metered(c, w, cfg, p)); + match c.profile.as_ref() { + Some(prof) => add_section(body, claude_plan_rows(prof, w, p)), + None => body, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rule the fossil taught: Claude Code's cache is worth reading only + /// while Claude Code itself would read it. + #[test] + fn claude_codes_cache_is_dropped_at_the_age_it_drops_it() { + // From its own reader, identical in 2.1.233 and 2.1.243: + // let r = Date.now() - n.data.fetchedAtMs; + // if (r < 0 || r > zNo) return null; // zNo = 3600000 + assert_eq!(CLAUDE_CACHE_TTL, 3600.0); + // The machine this was written on had one 9.6 days old, drawn as a + // current percentage because nothing here checked the age at all. + assert!(9.6 * 86400.0 > CLAUDE_CACHE_TTL); + assert!(59.0 * 60.0 <= CLAUDE_CACHE_TTL); + assert!(61.0 * 60.0 > CLAUDE_CACHE_TTL); + } + + /// A Claude Code cache file of a given age, as it writes them. + fn their_cache(dir: &std::path::Path, name: &str, age_secs: f64, pct: i64) -> String { + let path = dir.join(name); + let body = serde_json::json!({ + "cachedUsageUtilization": { + "fetchedAtMs": (now() - age_secs) * 1000.0, + "utilization": {"limits": [{"group": "session", "kind": "session", + "percent": pct, + "resets_at": "2026-08-16T13:59:59.851930+00:00"}]}, + } + }); + std::fs::write(&path, body.to_string()).unwrap(); + path.to_string_lossy().to_string() + } + + /// The bug this whole thing was written for, pinned on disk. + /// + /// A 9.6-day-old Claude Code cache and nothing of our own used to come + /// back as a percentage and get drawn as today's. Claude Code's own + /// reader returns null for it, and now so does this. + #[test] + fn the_tab_says_why_it_is_on_a_cached_reading() { + // The whole point of keeping curl's message. "cached 4m ago" leaves + // a reader to guess whether their login lapsed; this morning that + // guess cost an hour on a credential with three hours left on it. + let p = palette(); + let bare = |rows: Vec<String>| { + rows.iter() + .map(|r| { + let mut out = String::new(); + let mut cs = r.chars(); + while let Some(c) = cs.next() { + if c == '\x1b' { + for n in cs.by_ref() { + if n.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c) + } + } + out + }) + .collect::<Vec<_>>() + .join(" ") + }; + let with = |live: bool, why: &str| { + let d = Data { + quota: Some(serde_json::json!({ + "limits": [{"group": "session", "kind": "session", "percent": 20, + "resets_at": "2026-08-26T00:00:00.000000+00:00"}] + })), + quota_live: live, + quota_at: now() - 240.0, + quota_why: why.to_string(), + ..Data::default() + }; + bare(claude_quota(&d, 100, &p)) + }; + + let refused = with(false, "too many requests - something else is polling the same token"); + assert!(refused.contains("cached"), "{}", refused); + assert!( + refused.contains("too many requests"), + "the reason was dropped again: {}", + refused + ); + + // No reason recorded is the old wording, unchanged. + // The heading carries a · of its own, so the check is for the + // reason's words rather than for the separator - which is what the + // first version of this test got wrong, and why it was run before + // being believed. + let quiet = with(false, ""); + assert!(quiet.contains("cached"), "{}", quiet); + assert!( + !quiet.contains("too many requests"), + "a reason appeared from nowhere: {}", + quiet + ); + + // And a live reading says nothing about refusals at all. + let good = with(true, ""); + assert!(good.contains("live"), "{}", good); + assert!(!good.contains("cached"), "{}", good); + } + + #[test] + fn the_lane_takes_its_star_from_the_age_and_not_from_quota_live() { + // The wiring, not just the rule. A cached reading taken a moment ago + // must come through unflagged, and a live-flagged one taken days ago + // must not - which is the pair the old `!quota_live` got backwards. + let with = |live: bool, taken: f64| { + let d = Data { + quota: Some(serde_json::json!({ + "limits": [{"group": "session", "kind": "session", "percent": 20, + "resets_at": "2126-01-01T00:00:00.000000+00:00"}] + })), + quota_live: live, + quota_at: taken, + ..Data::default() + }; + let got = lanes(&d); + assert_eq!(got.len(), 1, "the fixture should make exactly one lane"); + got[0].stale + }; + assert!(!with(false, now() - 35.0), "a 35s cached reading is not old"); + assert!( + !with(false, now() - (CLAUDE_FRESH_FOR - 30.0)), + "just inside the window is not old" + ); + assert!( + with(false, now() - (CLAUDE_FRESH_FOR + 30.0)), + "past the window it has to be flagged" + ); + assert!( + with(true, now() - 9.6 * 86400.0), + "calling a nine-day-old reading live does not make it current" + ); + assert!(with(false, now() - 9.6 * 86400.0)); + } + + #[test] + fn a_reading_is_flagged_by_its_age_not_by_who_fetched_it() { + // The case that prompted this: a cached reading 35 seconds old sat + // under a star while a live one four minutes old carried none. Both + // are current; only one was being doubted. + assert!(!reading_is_old(now() - 35.0), "35s is fresher than most live reads"); + assert!(!reading_is_old(now() - CLAUDE_LIVE_TTL), "a live read may be this old"); + assert!( + !reading_is_old(now() - (CLAUDE_FRESH_FOR - 30.0)), + "just inside the window is still current" + ); + assert!( + reading_is_old(now() - (CLAUDE_FRESH_FOR + 30.0)), + "past twice the fetch interval it has to say so" + ); + // The one this all started with. + assert!(reading_is_old(now() - 9.6 * 86400.0)); + // A reading with no timestamp at all is not to be trusted quietly. + assert!(reading_is_old(0.0)); + } + + #[test] + fn a_fossil_of_theirs_is_not_a_reading() { + let dir = std::env::temp_dir().join(format!("tt-fossil-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let ours = dir.join("none.json").to_string_lossy().to_string(); + + let fossil = their_cache(&dir, "fossil.json", 9.6 * 86400.0, 11); + assert!( + stale_from(&ours, &fossil).is_none(), + "a cache its own owner discards was offered as a reading" + ); + + // Inside the hour it is still theirs to offer, and we take it. + let recent = their_cache(&dir, "recent.json", 30.0 * 60.0, 22); + let (got, at) = stale_from(&ours, &recent).expect("a half-hour-old reading is good"); + assert_eq!(got["limits"][0]["percent"], 22); + assert!(now() - at < 3600.0); + + // The boundary is theirs, not ours: an hour and a minute is out. + let edge = their_cache(&dir, "edge.json", 61.0 * 60.0, 33); + assert!(stale_from(&ours, &edge).is_none()); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// And with both on disk, the fresher one wins - which is the point of + /// keeping our own at all. + #[test] + fn our_snapshot_beats_their_cache_when_it_is_newer() { + let dir = std::env::temp_dir().join(format!("tt-both-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let ours = dir.join("ours.json").to_string_lossy().to_string(); + + // Ours taken just now, theirs half an hour ago and still valid. + save_snapshot_at(&ours, &serde_json::json!({ + "limits": [{"group": "session", "kind": "session", "percent": 77, + "resets_at": "2026-08-25T03:39:59.750751+00:00"}] + })); + let theirs = their_cache(&dir, "theirs.json", 30.0 * 60.0, 22); + let (got, _) = stale_from(&ours, &theirs).expect("something should come back"); + assert_eq!(got["limits"][0]["percent"], 77, "the older reading won"); + + // With theirs expired, ours is the only one left - which is the + // state this machine is actually in. + let fossil = their_cache(&dir, "fossil.json", 9.6 * 86400.0, 11); + let (got, _) = stale_from(&ours, &fossil).expect("ours should still answer"); + assert_eq!(got["limits"][0]["percent"], 77); + + // And with neither, nothing - not an error, just a machine that has + // never had a live reading. + let nowhere = dir.join("nothing.json").to_string_lossy().to_string(); + assert!(stale_from(&nowhere, &nowhere).is_none()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_saved_reading_comes_back_the_way_it_went_in() { + let dir = std::env::temp_dir().join(format!("tt-claude-{}", std::process::id())); + let path = dir.join("claude-usage.json"); + let path = path.to_string_lossy().to_string(); + let _ = std::fs::remove_file(&path); + // Nothing saved is not a failure - it is a machine that has never + // had a live reading. + assert!(read_snapshot_at(&path).is_none()); + + let u = serde_json::json!({ + "limits": [{"group": "session", "kind": "session", "percent": 42, + "resets_at": "2026-08-25T03:39:59.750751+00:00"}] + }); + save_snapshot_at(&path, &u); + let (back, at) = read_snapshot_at(&path).expect("saved and not read back"); + assert_eq!(back, u, "the reading changed shape crossing the disk"); + assert!( + (now() - at).abs() < 60.0, + "the stamp is the moment it was taken, not the epoch: {}", + at + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_later_reading_wins_and_neither_is_not_a_failure() { + assert_eq!(fresher(Some(("ours", 100.0)), Some(("theirs", 50.0))), Some(("ours", 100.0))); + assert_eq!(fresher(Some(("ours", 50.0)), Some(("theirs", 100.0))), Some(("theirs", 100.0))); + assert_eq!(fresher(Some(("ours", 10.0)), None), Some(("ours", 10.0))); + assert_eq!(fresher(None, Some(("theirs", 10.0))), Some(("theirs", 10.0))); + assert_eq!(fresher::<&str>(None, None), None); + // A tie goes to ours - we know when and how it was taken. + assert_eq!(fresher(Some(("ours", 42.0)), Some(("theirs", 42.0))), Some(("ours", 42.0))); + } +} diff --git a/widgets/src/bin/usage/codex.rs b/widgets/src/bin/usage/codex.rs new file mode 100644 index 0000000..43d4a32 --- /dev/null +++ b/widgets/src/bin/usage/codex.rs @@ -0,0 +1,1175 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! OpenAI Codex: its rollouts, and the account-wide quota the CLI itself reads. +//! +//! ~/.codex/logs is diagnostics and carries no counters, which is where an +//! earlier look stopped. The sessions directory is the one that counts: each +//! rollout is a JSONL transcript whose `token_count` events carry both the +//! session's running total and the per-turn delta. +//! +//! The per-turn deltas are what get summed. `total_token_usage` is +//! cumulative for the *session*, and a session spans several files - resuming +//! writes a new rollout for a session that already existed - so summing one +//! tail per file counted most sessions two or three times over. + +use std::collections::{HashMap, HashSet}; + +use chrono::{Datelike, Local, NaiveDate, TimeZone}; +use toys_core as tc; + +use crate::shared::*; +use crate::*; + +/// The endpoint the Codex CLI itself asks for the account's quota. Found by +/// reading how CodexBar does it (github.com/steipete/CodexBar), which +/// documents it. +const CODEX_USAGE_API: &str = "https://chatgpt.com/backend-api/wham/usage"; +/// Seconds; outside this range two `token_count` stamps do not bracket a turn. +const MIN_GAP: f64 = 0.5; +const MAX_GAP: f64 = 300.0; +/// Rollouts to tail looking for the newest quota snapshot. More than one +/// because a session that has only just opened has no answer back yet. +const SNAPSHOT_FILES: usize = 5; + +/// The token totals, in the names OpenAI uses for them. +/// +/// `input` already contains `cached`, and `output` already contains +/// `reasoning`; both are carried separately because they are worth reading, +/// and neither is added to `all`, which would count them twice. +#[derive(Clone, Default)] +struct Totals { + input: f64, + output: f64, + reasoning: f64, + cached: f64, + all: f64, +} + +/// What the rollouts on this machine recorded, plus the account's quota. +#[derive(Clone, Default)] +pub struct Data { + /// False when there are no rollouts here at all - which is a fact about + /// this machine, not about the account, so the quota is still shown. + ok: bool, + /// The live account-wide reading from the endpoint the CLI uses. + live: Option<serde_json::Value>, + /// The rate_limits the newest rollout recorded, for when the live call + /// cannot run. A snapshot from whenever Codex last spoke to the server. + limits: Option<serde_json::Value>, + sessions: usize, + files: usize, + /// Modification time of the newest rollout. + last: f64, + total: Totals, + /// Output tokens per second, sorted, from the newest rollout. + rates: Vec<f64>, + /// day -> model -> tokens by priced kind, plus reasoning. + daily: HashMap<String, HashMap<String, Tokens>>, +} + +/// Account-wide quota, live from the same endpoint the Codex CLI uses. +/// +/// The rollouts carry a rate_limits snapshot, but only from whenever Codex +/// last ran - it can be days stale. This is the current figure, and it is the +/// account rather than this machine. +/// +/// The token comes from ~/.codex/auth.json and goes to the same host Codex +/// itself talks to; it is never printed. Any failure falls back to the +/// snapshot, so an expired token costs freshness and nothing else. +fn codex_live() -> Option<serde_json::Value> { + let auth = read_json(&under_home(".codex/auth.json"))?; + let tok = match text(&auth["tokens"], "access_token") { + s if !s.is_empty() => s, + _ => text(&auth, "access_token"), + }; + if tok.is_empty() { + return None; + } + get_json( + CODEX_USAGE_API, + &[ + ("Authorization", &format!("Bearer {}", tok)), + ("User-Agent", "terminal-toys"), + ], + 20, + ) +} + +/// Per-turn, per-model token counts from one rollout's text. +/// +/// The model is not on the token counts: it arrives in `turn_context`, one +/// per turn, and applies to the `token_count` events that follow it. So the +/// lines are walked in order, carrying the model forward. +/// +/// `last_token_usage` is the per-turn delta - the running total is on every +/// event, and summing those would count the session once per turn. Within +/// input_tokens, cached_input_tokens is the cheaper subset, and within +/// output_tokens the reasoning tokens are already included, so only the +/// uncached remainder is charged at the input rate. +/// +/// Keyed by session and timestamp, not by filename and not by a sequence +/// number: a resumed session replays its earlier events into a new file, so +/// the same turn is written twice with the same stamp. Sequence numbers +/// restart per file and would pair unrelated events, and `ordinal` is absent +/// from older rollouts. +fn rollout_records(body: &str, fallback: &str) -> HashMap<String, (String, String, Tokens)> { + let mut records: HashMap<String, (String, String, Tokens)> = HashMap::new(); + let mut session = fallback.to_string(); + let mut model = String::new(); + for line in body.lines() { + if !line.contains("\"model\"") + && !line.contains("\"token_count\"") + && !line.contains("\"session_meta\"") + { + continue; + } + let Ok(r) = serde_json::from_str::<serde_json::Value>(line) else { + continue; + }; + let payload = &r["payload"]; + match text(&r, "type").as_str() { + "session_meta" => { + let id = text(payload, "session_id"); + if !id.is_empty() { + session = id; + } + continue; + } + "turn_context" => { + let named = text(payload, "model"); + if !named.is_empty() { + model = named; + } + continue; + } + _ => {} + } + if text(payload, "type") != "token_count" || model.is_empty() { + continue; + } + let used = &payload["info"]["last_token_usage"]; + let stamp = text(&r, "timestamp"); + let Some(when) = iso_epoch(&stamp) else { + continue; + }; + let cached = num(used, "cached_input_tokens"); + let mut got = empty_tokens(); + got.insert("reasoning".into(), num(used, "reasoning_output_tokens")); + *got.get_mut("input").unwrap() = (num(used, "input_tokens") - cached).max(0.0); + *got.get_mut("cache_read").unwrap() = cached; + *got.get_mut("cache_write").unwrap() = num(used, "cache_write_input_tokens"); + *got.get_mut("output").unwrap() = num(used, "output_tokens"); + if got.values().all(|n| *n == 0.0) { + continue; + } + let day = Local + .timestamp_opt(when as i64, 0) + .single() + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + records.insert(format!("{}\u{0}{}", session, stamp), (day, model.clone(), got)); + } + records +} + +/// One rollout's records, parsed once. +/// +/// Cached on (mtime, size): a finished rollout never changes, and some run to +/// thirty megabytes, so the full parse happens once per file rather than on +/// every refresh. +fn scan_rollout(caches: &mut Caches, path: &str) -> HashMap<String, (String, String, Tokens)> { + use std::os::unix::fs::MetadataExt; + let Ok(meta) = std::fs::metadata(path) else { + return HashMap::new(); + }; + let key = (meta.mtime() as u64, meta.size()); + if let Some((had, records)) = caches.transcripts.get(path) { + if *had == key { + return records.clone(); + } + } + let Ok(body) = std::fs::read_to_string(path) else { + return HashMap::new(); + }; + // The basename only stands in until session_meta names the session. Two + // rollouts in different directories can share one, so it is a last resort + // rather than an identifier. + let base = path.rsplit('/').next().unwrap_or(path); + let records = rollout_records(&body, base); + caches + .transcripts + .insert(path.to_string(), (key, records.clone())); + records +} + +/// Every rollout on this machine, newest first. +fn rollout_files() -> Vec<String> { + let mut files = Vec::new(); + walk(&under_home(".codex/sessions"), ".jsonl", &mut files); + newest_first(files) +} + +/// Per-day, per-model tokens, and how many distinct sessions they came from. +/// +/// Sessions rather than rollout files: thirty rollouts here hold eight +/// sessions, because resuming writes a new file for a session that already +/// existed. Counting files and calling them sessions was the same mistake +/// that made the totals wrong, in the label. +fn merge_days( + seen: HashMap<String, (String, String, Tokens)>, +) -> (HashMap<String, HashMap<String, Tokens>>, usize) { + let mut sessions: HashSet<String> = HashSet::new(); + let mut merged: HashMap<String, HashMap<String, Tokens>> = HashMap::new(); + for (key, (day, model, tokens)) in seen { + if let Some(id) = key.split('\u{0}').next() { + sessions.insert(id.to_string()); + } + let bucket = merged + .entry(day) + .or_default() + .entry(model) + .or_insert_with(empty_tokens); + // Every kind, not just the priced ones: reasoning is carried + // alongside them and iterating the rate card would drop it. + for (kind, n) in &tokens { + *bucket.entry(kind.clone()).or_insert(0.0) += n; + } + } + (merged, sessions.len()) +} + +/// Totals from the de-duplicated per-turn deltas. +/// +/// Not from the rollout tails. Summing one cumulative tail per file counted +/// most sessions two or three times over: 664.5M against a true 370.0M for +/// the primary model. Summing the deltas instead reproduces Codex's own +/// cumulative figure exactly on four of eight sessions, and picks up the +/// review model besides, which the session total never included. +fn codex_totals(daily: &HashMap<String, HashMap<String, Tokens>>) -> Totals { + let mut out = Totals::default(); + let at = |t: &Tokens, kind: &str| t.get(kind).copied().unwrap_or(0.0); + for models in daily.values() { + for tokens in models.values() { + out.input += at(tokens, "input") + at(tokens, "cache_read"); + out.cached += at(tokens, "cache_read"); + out.output += at(tokens, "output"); + out.reasoning += at(tokens, "reasoning"); + } + } + out.all = out.input + out.output; + out +} + +/// Output tokens per second, from the newest rollout only. +/// +/// The gap between consecutive `token_count` events, which brackets one +/// turn's generation. The median is what gets shown: it barely moves +/// whichever way the outliers are trimmed, while the maximum moves by a +/// factor of twenty on the same data. +fn codex_rates(path: &str) -> Vec<f64> { + let mut rates: Vec<f64> = Vec::new(); + let mut prev: Option<f64> = None; + for line in tail_lines(path, 4 * 1024 * 1024) { + if !line.contains("\"token_count\"") { + continue; + } + let Ok(d) = serde_json::from_str::<serde_json::Value>(&line) else { + continue; + }; + let Some(at) = iso_epoch(&text(&d, "timestamp")) else { + continue; + }; + let out = num(&d["payload"]["info"]["last_token_usage"], "output_tokens"); + if let Some(before) = prev { + let gap = at - before; + if out > 0.0 && gap > MIN_GAP && gap < MAX_GAP { + rates.push(out / gap); + } + } + prev = Some(at); + } + rates.sort_by(f64::total_cmp); + rates +} + +/// The newest rate_limits snapshot on disk. +/// +/// The server returns the account's windows with each response and the +/// rollout writes them down, so the last one in the newest file is the +/// freshest thing here. Only the tail is read: this is the fallback for a +/// failed live call, and re-reading every rollout in full to find a number +/// that is repeated at the end of one of them would be daft. +fn newest_limits(files: &[String]) -> Option<serde_json::Value> { + for path in files.iter().take(SNAPSHOT_FILES) { + let mut found = None; + for line in tail_lines(path, TAIL) { + if !line.contains("\"rate_limits\"") { + continue; + } + let Ok(d) = serde_json::from_str::<serde_json::Value>(&line) else { + continue; + }; + let payload = &d["payload"]; + let limits = match payload["rate_limits"].is_object() { + true => payload["rate_limits"].clone(), + false => payload["info"]["rate_limits"].clone(), + }; + if limits.is_object() { + found = Some(limits); + } + } + if found.is_some() { + return found; + } + } + None +} + +pub fn read(caches: &mut Caches, _cfg: &Config) -> Data { + let mut codex = Data { + live: cached(caches, "codex", LIVE_TTL, codex_live), + ..Data::default() + }; + let files = rollout_files(); + let Some(newest) = files.first().cloned() else { + return codex; + }; + use std::os::unix::fs::MetadataExt; + codex.ok = true; + codex.files = files.len(); + codex.last = std::fs::metadata(&newest) + .map(|m| m.mtime() as f64) + .unwrap_or(0.0); + codex.limits = newest_limits(&files); + let mut seen: HashMap<String, (String, String, Tokens)> = HashMap::new(); + for path in &files { + seen.extend(scan_rollout(caches, path)); + } + let (daily, sessions) = merge_days(seen); + codex.total = codex_totals(&daily); + codex.daily = daily; + codex.sessions = sessions; + codex.rates = codex_rates(&newest); + codex +} + +/// A window's length, said the way a reader would say it. +fn window_name(secs: Option<f64>) -> String { + let secs = secs.unwrap_or(0.0) as i64; + if secs >= 86400 { + format!("{}d", secs / 86400) + } else if secs > 0 { + format!("{}h", secs / 3600) + } else { + "?".into() + } +} + +/// A JSON scalar as the text a reader would recognise. +/// +/// Balances and spend limits arrive as numbers from one endpoint and as +/// strings from another, and `"12"` on screen with the quotes still on it is +/// not a balance. +fn scalar(v: &serde_json::Value) -> String { + if let Some(n) = v.as_f64() { + return format!("{}", n); + } + match v.as_str() { + Some(s) => s.to_string(), + None => "0".into(), + } +} + +/// One quota bar's worth of numbers, from whichever source answered. +struct Win { + /// Empty for the account's own windows; a feature name for the rest. + name: String, + pct: f64, + secs: f64, + reset: Option<f64>, +} + +/// The account's quota windows, live if the endpoint answered and from the +/// last session's snapshot if it did not. +/// +/// The one genuine quota figure any of these agents publishes: the server +/// sends it back with each response, and the rollout records it. +fn codex_quota(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let mut wins: Vec<Win> = Vec::new(); + let mut plan = String::new(); + if let Some(live) = d.live.as_ref() { + plan = text(live, "plan_type"); + for key in ["primary_window", "secondary_window"] { + let win = &live["rate_limit"][key]; + if win["used_percent"].is_null() { + continue; + } + wins.push(Win { + name: String::new(), + pct: num(win, "used_percent"), + secs: num(win, "limit_window_seconds"), + reset: win["reset_at"].as_f64(), + }); + } + // Some features meter separately from the account's general usage - + // Spark is one - and each arrives named, with its own window and + // reset. Rendering the list rather than the one name we know keeps + // any future feature working without an edit. + for extra in live["additional_rate_limits"].as_array().into_iter().flatten() { + let win = &extra["rate_limit"]["primary_window"]; + if win["used_percent"].is_null() { + continue; + } + wins.push(Win { + name: match text(extra, "limit_name") { + s if s.is_empty() => "?".into(), + s => s, + }, + pct: num(win, "used_percent"), + secs: num(win, "limit_window_seconds"), + reset: win["reset_at"].as_f64(), + }); + } + } + let live_answered = !wins.is_empty(); + if !live_answered { + // A snapshot with no percentage in it is not a lane. The percentage + // is the only number on the row that cannot be inferred from the + // others, so a window without one has nothing to draw. + let win = d.limits.as_ref().map(|l| &l["primary"]); + if let Some(win) = win.filter(|x| !x["used_percent"].is_null()) { + wins.push(Win { + name: String::new(), + pct: num(win, "used_percent"), + secs: num(win, "window_minutes") * 60.0, + reset: win["resets_at"].as_f64(), + }); + } + } + if wins.is_empty() { + return Vec::new(); + } + let source = if live_answered { "live" } else { "from the last session" }; + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── QUOTA ── ".into()), + ( + if live_answered { p.ok.as_str() } else { p.warn.as_str() }, + source.into(), + ), + ( + p.dim.as_str(), + crate::claude::scope_phrase(w, 13 + source.len() + plan.len()).into(), + ), + (p.dim.as_str(), plan), + ], + w - 1, + )]; + + // Alone, the account-wide lanes are told apart by their window and a bare + // "7d" is clear enough. Beside a named one it is not, so a lane says what + // it covers only when there is something to confuse it with. + let named = wins.iter().any(|x| !x.name.is_empty()); + let labels = |short: bool| -> Vec<String> { + wins.iter() + .map(|x| { + // Spell a feature out while there is room; below that the + // last segment carries it - GPT-5.3-Codex-Spark is Spark. + let mut name = match short && !x.name.is_empty() { + true => x.name.rsplit('-').next().unwrap_or("").to_string(), + false => x.name.clone(), + }; + if name.is_empty() && named { + name = "overall".into(); + } + format!("{} {}", name, window_name(Some(x.secs))) + .trim() + .to_string() + }) + .collect() + }; + let mut lab = labels(false); + let widest = |list: &[String]| list.iter().map(|x| x.chars().count()).max().unwrap_or(0); + if w as i64 - 32 - (widest(&lab) as i64) < 20 { + lab = labels(true); + } + let label_w = widest(&lab).max(9); + let hue = agent_hue("codex"); + for (win, label) in wins.iter().zip(&lab) { + let used = (win.pct / 100.0).clamp(0.0, 1.0); + let secs = (win.secs > 0.0).then_some(win.secs); + let when = match win.reset { + None => String::new(), + Some(at) if at - now() > 0.0 => format!("resets in {}", left_span(at - now())), + Some(_) => "resetting".into(), + }; + let (pace_colour, pace_txt) = pace_cell(lead(win.pct, secs, win.reset), p); + let mut line: Vec<(String, String)> = + vec![(p.dim.clone(), format!(" {} ", tc::pad(label, label_w)))]; + line.extend(paced_bar( + used, + elapsed_of(secs, win.reset), + w.saturating_sub(34 + label_w).max(8), + hue, + p, + )); + line.push((pct_colour(win.pct, hue, p), pct_text(win.pct))); + line.push((pace_colour, pace_txt)); + line.push((p.dim.clone(), format!(" {}", when))); + let refs: Vec<(&str, String)> = line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + rows.push(String::new()); + rows +} + +/// What this machine's rollouts add up to. +fn codex_totals_rows(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let t = &d.total; + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── TOTALS ── ".into()), + ( + p.dim.as_str(), + format!("{} sessions · newest {} ago", d.sessions, ago(d.last)), + ), + ], + w - 1, + )]; + let cells: Vec<(&str, String, &str)> = vec![ + ("input tokens", big_num(t.input), p.txt.as_str()), + ("output tokens", big_num(t.output), p.agent.as_str()), + ("reasoning tokens", big_num(t.reasoning), p.txt.as_str()), + ("cached input", big_num(t.cached), p.dim.as_str()), + ("all tokens", big_num(t.all), p.txt.as_str()), + ("rollout files", format!("{}", d.files), p.dim.as_str()), + ]; + let label_w = cells.iter().map(|c| c.0.len()).max().unwrap_or(0); + // Two columns while both fit; one when they do not. Spending extra width + // on more content rather than on padding is the house rule, and a value + // column under eight cells cannot hold "1.2M". + let ncols = if (w as i64 - 2) / 2 - label_w as i64 - 3 >= 8 { 2 } else { 1 }; + let val_w = ((w - 2) / ncols).saturating_sub(label_w + 3).max(5); + for chunk in cells.chunks(ncols) { + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, value, colour) in chunk { + line.push((p.dim.as_str(), format!(" {} ", tc::pad(label, label_w)))); + line.push((colour, tc::pad(value, val_w))); + } + rows.push(tc::seg(&line, w - 1)); + } + rows +} + +/// How fast it generates, and the shape of the distribution behind the median. +fn codex_rate_rows(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let rates = &d.rates; + if rates.is_empty() { + return Vec::new(); + } + let med = rates[rates.len() / 2]; + let p90 = rates[((rates.len() as f64 * 0.9) as usize).min(rates.len() - 1)]; + let top = rates[rates.len() - 1]; + let mut rows = vec![ + tc::seg( + &[ + (p.lbl.as_str(), " ── OUTPUT RATE ── ".into()), + ( + p.dim.as_str(), + format!("newest session, {} turns", rates.len()), + ), + ], + w - 1, + ), + tc::seg( + &[ + (p.dim.as_str(), " median ".into()), + (p.agent.as_str(), format!("{:.0}", med)), + (p.dim.as_str(), " tok/s p90 ".into()), + (p.txt.as_str(), format!("{:.0}", p90)), + (p.dim.as_str(), " max ".into()), + (p.txt.as_str(), format!("{:.0}", top)), + ], + w - 1, + ), + ]; + // The bucket count stays tied to the sample count - twenty-eight turns + // spread over fifty columns is a comb, not a distribution - but each + // bucket is then drawn as wide as the pane allows, so the chart fills its + // line instead of stopping a third of the way in. + let hi = if top > 0.0 { top } else { 1.0 }; + let count = rates.len().min(w.saturating_sub(6)).max(10); + let mut buckets = vec![0.0f64; count]; + for r in rates { + let at = ((r / hi * (count - 1) as f64) as usize).min(count - 1); + buckets[at] += 1.0; + } + let mut cols: Vec<(f64, String)> = Vec::new(); + for (b, wide) in buckets.iter().zip(tc::spread(count, w.saturating_sub(3).max(10))) { + cols.extend(std::iter::repeat_n((*b, p.agent.clone()), wide)); + } + for line in tc::vbars(&cols, 3, 0.0) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + rows.push(tc::seg( + &[ + (tc::RST, " ".into()), + (p.grid.as_str(), "─".repeat(cols.len())), + ], + w - 1, + )); + let right = format!("{:.0} tok/s", top); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " 0 tok/s".into()), + ( + p.dim.as_str(), + " ".repeat(cols.len().saturating_sub(8 + right.len()).max(1)), + ), + (p.dim.as_str(), right), + ], + w - 1, + )); + rows +} + +/// Tokens per day, as a calendar. +/// +/// Summed from the de-duplicated per-turn deltas, the same figures the +/// totals and the cost come from. A per-file sum would count a resumed +/// session's replayed turns again and put a peak on the calendar that never +/// happened. +fn codex_calendar(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let mut totals: HashMap<NaiveDate, f64> = HashMap::new(); + for (day, models) in &d.daily { + let Ok(at) = NaiveDate::parse_from_str(day, "%Y-%m-%d") else { + continue; + }; + *totals.entry(at).or_insert(0.0) += models.values().map(total_tokens).sum::<f64>(); + } + let peak = totals.values().cloned().fold(0.0f64, f64::max); + let Some(cal) = day_calendar(&totals, w, CODEX_STEPS, None, p) else { + return Vec::new(); + }; + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── TOKENS / DAY ── ".into()), + (p.dim.as_str(), "peak ".into()), + (p.agent.as_str(), big_num(peak)), + ( + p.dim.as_str(), + format!( + " on {}", + cal.best + .map(|b| format!("{} {}", MONTHS[b.month0() as usize], b.day())) + .unwrap_or_else(|| "--".into()) + ), + ), + ], + w - 1, + )]; + for line in &cal.rows { + let refs: Vec<(&str, String)> = line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + let mut legend: Vec<(&str, String)> = vec![(p.dim.as_str(), " Less ".into())]; + let swatches: Vec<String> = CODEX_STEPS.iter().map(|(r, g, b)| tc::rgb(*r, *g, *b)).collect(); + for colour in &swatches { + legend.push((colour.as_str(), "█".into())); + } + legend.push((p.dim.as_str(), " More".into())); + rows.push(tc::seg(&legend, w - 1)); + rows +} + +fn codex_metered(d: &Data, w: usize, cfg: &Config, p: &Palette) -> Vec<String> { + metered_rows( + &[ + ("today".to_string(), crate::claude::window_models(&d.daily, 1)), + ("30 days".to_string(), crate::claude::window_models(&d.daily, 30)), + ], + w, + "", + "codex", + "this machine", + "CLI rollouts only. Codex bills Cloud, Web, Desktop and the rest to \ + the same account, and none of those leave anything on this disk to \ + count.", + cfg, + p, + ) +} + +/// Plan type and a credit balance - all Codex publishes about the plan. +/// +/// Three lines rather than the section Copilot and Cursor get, because three +/// lines is genuinely all there is. Credits belong here rather than under the +/// quota bars: they are what the plan grants, not a window. +fn codex_plan_rows(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let null = serde_json::Value::Null; + let live = d.live.as_ref().unwrap_or(&null); + let limits = d.limits.as_ref().unwrap_or(&null); + let plan = match text(live, "plan_type") { + s if !s.is_empty() => s, + _ => text(limits, "plan_type"), + }; + let credits = match live["credits"].is_object() { + true => &live["credits"], + false => &limits["credits"], + }; + let mut pairs: Vec<(String, String)> = Vec::new(); + if credits.is_object() { + pairs.push(( + "credits".into(), + match credits["unlimited"].as_bool().unwrap_or(false) { + true => "unlimited".into(), + false => scalar(&credits["balance"]), + }, + )); + } + let limit = &live["spend_control"]["individual_limit"]; + if !limit.is_null() { + pairs.push(("spend limit".into(), scalar(limit))); + } + // Nothing published is not a plan called "unknown", which is what the + // shared block would otherwise print. + if plan.is_empty() && pairs.is_empty() { + return Vec::new(); + } + plan_rows(&plan, &pairs, w, "", None, "", p) +} + +/// Every quota Codex publishes, for the summary screen. +/// +/// The live account-wide windows only. The snapshot the tab falls back to is +/// a reading from whenever Codex last ran, and this screen ranks agents +/// against each other - a day-old percentage sorted beside live ones would +/// put the wrong agent at the top. +pub fn lanes(d: &Data) -> Vec<Lane> { + let Some(live) = d.live.as_ref() else { + // The tab falls back to the rollout snapshot here and says "from + // the last session"; the summary used to show nothing at all, so + // Codex silently left a screen that names every other agent. The + // snapshot keeps the shape it has on disk - window_minutes, not + // seconds - and is marked stale, which is what that flag is for. + let win = d.limits.as_ref().map(|l| &l["primary"]); + let Some(win) = win.filter(|x| !x["used_percent"].is_null()) else { + return Vec::new(); + }; + let minutes = num(win, "window_minutes"); + return vec![Lane { + label: window_name(Some(minutes * 60.0)), + pct: num(win, "used_percent"), + window_secs: (minutes > 0.0).then_some(minutes * 60.0), + reset: win["resets_at"].as_f64(), + stale: true, + projected: false, + apart: false, + }]; + }; + let mut out: Vec<Lane> = Vec::new(); + for key in ["primary_window", "secondary_window"] { + let win = &live["rate_limit"][key]; + if win["used_percent"].is_null() { + continue; + } + let secs = win["limit_window_seconds"].as_f64(); + out.push(Lane { + label: window_name(secs), + pct: num(win, "used_percent"), + window_secs: secs, + reset: win["reset_at"].as_f64(), + stale: false, + projected: false, + apart: false, + }); + } + for extra in live["additional_rate_limits"].as_array().into_iter().flatten() { + let win = &extra["rate_limit"]["primary_window"]; + if win["used_percent"].is_null() { + continue; + } + let name = match text(extra, "limit_name") { + s if s.is_empty() => "?".into(), + s => s, + }; + let secs = win["limit_window_seconds"].as_f64(); + out.push(Lane { + label: format!("{} {}", name.rsplit('-').next().unwrap_or("?"), window_name(secs)), + pct: num(win, "used_percent"), + window_secs: secs, + reset: win["reset_at"].as_f64(), + stale: false, + projected: false, + apart: false, + }); + } + out +} + +/// The whole tab: the quota, what this machine recorded, what it cost, and +/// which subscription the percentages are percentages of. +pub fn tab(d: &Data, w: usize, _h: usize, cfg: &Config, p: &Palette) -> Vec<String> { + let mut rows = codex_quota(d, w, p); + if !d.ok { + // The quota above is the account's and is true whatever this machine + // has on disk, so it stays; only the local half is missing. + rows.extend(no_local( + "No session rollouts on this machine.", + run_hint("codex"), + w, + p, + )); + return add_section(rows, codex_plan_rows(d, w, p)); + } + rows.extend(codex_totals_rows(d, w, p)); + rows = add_section(rows, codex_rate_rows(d, w, p)); + rows = add_section(rows, codex_calendar(d, w, p)); + rows.push(String::new()); + for line in wrap_text( + "Tokens and rate are measured here, from the rollouts. Quota is the \ + account's, fetched from the same endpoint the Codex CLI uses.", + w.saturating_sub(4).max(20), + ) { + rows.push(tc::seg(&[(p.dim.as_str(), format!(" {}", line))], w - 1)); + } + let body = add_section(rows, codex_metered(d, w, cfg, p)); + add_section(body, codex_plan_rows(d, w, p)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Two `token_count` events with the model named once before them. + fn one_session(id: &str) -> String { + [ + format!( + r#"{{"type":"session_meta","payload":{{"session_id":"{}"}}}}"#, + id + ), + r#"{"type":"turn_context","payload":{"model":"gpt-5.3-codex"}}"#.to_string(), + r#"{"type":"event_msg","timestamp":"2026-08-16T10:00:00.000Z","payload": + {"type":"token_count","info":{"last_token_usage": + {"input_tokens":1000,"cached_input_tokens":800,"output_tokens":300, + "reasoning_output_tokens":120}}}}"# + .replace('\n', ""), + r#"{"type":"event_msg","timestamp":"2026-08-16T10:00:20.000Z","payload": + {"type":"token_count","info":{"last_token_usage": + {"input_tokens":2000,"cached_input_tokens":1500,"output_tokens":500, + "reasoning_output_tokens":200}}}}"# + .replace('\n', ""), + ] + .join("\n") + } + + #[test] + fn cached_input_is_split_out_of_the_input_it_arrived_inside() { + let got = rollout_records(&one_session("s-1"), "fallback.jsonl"); + assert_eq!(got.len(), 2); + let (_, model, tokens) = got + .values() + .find(|(_, _, t)| t["output"] == 300.0) + .expect("the first turn"); + assert_eq!(model, "gpt-5.3-codex"); + // 1000 input of which 800 were cached: only 200 are charged at the + // input rate, and the cached 800 at the far cheaper one. + assert_eq!(tokens["input"], 200.0); + assert_eq!(tokens["cache_read"], 800.0); + // Reasoning is carried, and is already inside output. + assert_eq!(tokens["reasoning"], 120.0); + } + + #[test] + fn a_turn_takes_the_model_from_the_context_before_it() { + // The model is not on the token counts: it arrives in turn_context + // and applies to everything that follows, until the next one. + let body = [ + r#"{"type":"turn_context","payload":{"model":"gpt-5.3-codex"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-16T10:00:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"output_tokens":10}}}}"#, + r#"{"type":"turn_context","payload":{"model":"codex-auto-review"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-16T10:01:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"output_tokens":20}}}}"#, + ] + .join("\n"); + let got = rollout_records(&body, "fallback.jsonl"); + let mut models: Vec<&str> = got.values().map(|(_, m, _)| m.as_str()).collect(); + models.sort(); + assert_eq!(models, vec!["codex-auto-review", "gpt-5.3-codex"]); + } + + #[test] + fn a_turn_before_any_model_is_named_is_not_guessed_at() { + // Without a turn_context there is no model to attribute the tokens + // to, and attributing them to the wrong one would be worse than + // leaving them out. + let body = r#"{"type":"event_msg","timestamp":"2026-08-16T10:00:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"output_tokens":10}}}}"#; + assert!(rollout_records(body, "fallback.jsonl").is_empty()); + } + + #[test] + fn a_replayed_session_is_counted_once() { + // Resuming writes a new rollout that replays the earlier turns, with + // the same session id and the same stamps. Summed per file that would + // count them twice - the fault that inflated Claude's figures. + let mut seen: HashMap<String, (String, String, Tokens)> = HashMap::new(); + seen.extend(rollout_records(&one_session("s-1"), "first.jsonl")); + seen.extend(rollout_records(&one_session("s-1"), "second.jsonl")); + assert_eq!(seen.len(), 2); + let (daily, sessions) = merge_days(seen); + assert_eq!(sessions, 1); + let totals = codex_totals(&daily); + // 1000 + 2000 input, cached included; 300 + 500 out; 120 + 200 of + // that reasoning. + assert_eq!(totals.input, 3000.0); + assert_eq!(totals.output, 800.0); + assert_eq!(totals.cached, 2300.0); + assert_eq!(totals.reasoning, 320.0); + // Reasoning sits inside output and cached inside input, so neither is + // added again: 3000 + 800. + assert_eq!(totals.all, 3800.0); + } + + #[test] + fn two_sessions_are_two_sessions_however_many_files_they_took() { + let mut seen: HashMap<String, (String, String, Tokens)> = HashMap::new(); + for (id, file) in [("s-1", "a"), ("s-1", "b"), ("s-2", "c")] { + seen.extend(rollout_records(&one_session(id), file)); + } + let (_, sessions) = merge_days(seen); + assert_eq!(sessions, 2); + } + + #[test] + fn a_window_is_named_by_how_long_it_is() { + assert_eq!(window_name(Some(7.0 * 86400.0)), "7d"); + assert_eq!(window_name(Some(5.0 * 3600.0)), "5h"); + // No length published is a question mark, not a zero. + assert_eq!(window_name(None), "?"); + assert_eq!(window_name(Some(0.0)), "?"); + } + + #[test] + fn a_named_feature_lane_keeps_only_its_last_segment() { + // GPT-5.3-Codex-Spark is Spark: the family name is already implied by + // the tab it is sitting on. + let d = Data { + live: Some( + serde_json::from_str( + r#"{"plan_type":"pro", + "rate_limit":{ + "primary_window":{"used_percent":26.0,"limit_window_seconds":604800,"reset_at":1000}, + "secondary_window":{"used_percent":4.5,"limit_window_seconds":18000,"reset_at":2000}}, + "additional_rate_limits":[ + {"limit_name":"GPT-5.3-Codex-Spark", + "rate_limit":{"primary_window":{"used_percent":12.0,"limit_window_seconds":86400,"reset_at":3000}}}]}"#, + ) + .expect("a live reading"), + ), + ..Data::default() + }; + let got = lanes(&d); + let labels: Vec<&str> = got.iter().map(|l| l.label.as_str()).collect(); + assert_eq!(labels, vec!["7d", "5h", "Spark 1d"]); + assert_eq!(got[0].pct, 26.0); + assert_eq!(got[2].window_secs, Some(86400.0)); + assert_eq!(got[2].reset, Some(3000.0)); + // Live is live: nothing here is a cached reading. + assert!(got.iter().all(|l| !l.stale)); + } + + #[test] + fn a_window_with_no_percentage_is_not_a_lane() { + // The percentage is the only number on the row that cannot be worked + // out from the others, so a window without one has nothing to say. + let d = Data { + live: Some( + serde_json::from_str( + r#"{"rate_limit":{ + "primary_window":{"limit_window_seconds":604800}, + "secondary_window":{"used_percent":4.5,"limit_window_seconds":18000}}, + "additional_rate_limits":[ + {"limit_name":"Spark","rate_limit":{"primary_window":{}}}]}"#, + ) + .expect("a live reading"), + ), + ..Data::default() + }; + let got = lanes(&d); + assert_eq!(got.len(), 1); + assert_eq!(got[0].label, "5h"); + } + + #[test] + fn an_agent_that_answered_nothing_publishes_no_lanes() { + assert!(lanes(&Data::default()).is_empty()); + } + + #[test] + fn the_snapshot_stands_in_only_when_the_live_call_answered_nothing() { + let p = palette(); + let d = Data { + limits: Some( + serde_json::from_str( + r#"{"primary":{"used_percent":71.0,"window_minutes":10080}}"#, + ) + .expect("a snapshot"), + ), + ..Data::default() + }; + let rows = codex_quota(&d, 90, &p).join(" "); + assert!(rows.contains("from the last session"), "{}", rows); + assert!(rows.contains("71%"), "{}", rows); + // And it reaches the summary too, marked stale. The alternative was + // Codex disappearing from a screen that names every other agent + // while its own tab showed a quota - and the summary draws a stale + // lane as "cached" rather than as a reset time, so it is not passed + // off as a live figure beside live ones. + let got = lanes(&d); + assert_eq!(got.len(), 1, "{:?}", got); + assert!(got[0].stale); + assert_eq!(got[0].pct, 71.0); + assert_eq!(got[0].window_secs, Some(10080.0 * 60.0)); + assert_eq!(got[0].label, "7d"); + } + + #[test] + fn a_snapshot_with_no_window_length_still_reaches_the_summary() { + // window_minutes absent reads as 0, which is not a window. The lane + // must still appear - the percentage is the part that matters - but + // without a length nothing can pace it, so window_secs stays None + // and the summary draws no pace mark rather than an invented one. + let d = Data { + limits: Some( + serde_json::from_str(r#"{"primary":{"used_percent":40.0}}"#) + .expect("a snapshot"), + ), + ..Data::default() + }; + let got = lanes(&d); + assert_eq!(got.len(), 1); + assert_eq!(got[0].window_secs, None); + assert!(got[0].stale); + } + + #[test] + fn a_snapshot_without_a_percentage_draws_nothing_rather_than_crashing() { + let p = palette(); + let d = Data { + limits: Some( + serde_json::from_str(r#"{"primary":{"window_minutes":10080}}"#).expect("a snapshot"), + ), + ..Data::default() + }; + assert!(codex_quota(&d, 90, &p).is_empty()); + } + + #[test] + fn a_plan_nobody_published_is_left_out_rather_than_called_unknown() { + let p = palette(); + assert!(codex_plan_rows(&Data::default(), 90, &p).is_empty()); + let d = Data { + live: Some( + serde_json::from_str(r#"{"plan_type":"pro","credits":{"balance":12.5}}"#) + .expect("a live reading"), + ), + ..Data::default() + }; + let rows = codex_plan_rows(&d, 90, &p).join(" "); + assert!(rows.contains("pro"), "{}", rows); + // A balance is a number, and quotes around it are not part of one. + assert!(rows.contains("12.5"), "{}", rows); + } + + #[test] + fn every_section_draws_at_every_pane_width() { + // The tab adapts rather than truncates - two columns of totals + // become one, a spelled-out feature name becomes its last segment, + // and the histogram fills whatever line it is given. Each of those + // is arithmetic on a width, and each is a place a narrow pane has + // put a widget on the floor before. + let p = palette(); + let cfg = Config::default(); + let seen = rollout_records(&one_session("s-1"), "a.jsonl"); + let (daily, sessions) = merge_days(seen); + let d = Data { + ok: true, + live: Some( + serde_json::from_str( + r#"{"plan_type":"pro", + "credits":{"balance":40}, + "rate_limit":{ + "primary_window":{"used_percent":26.0,"limit_window_seconds":604800}, + "secondary_window":{"used_percent":4.5,"limit_window_seconds":18000}}, + "additional_rate_limits":[ + {"limit_name":"GPT-5.3-Codex-Spark", + "rate_limit":{"primary_window":{"used_percent":12.0,"limit_window_seconds":18000}}}]}"#, + ) + .expect("a live reading"), + ), + sessions, + files: 2, + last: now() - 60.0, + total: codex_totals(&daily), + rates: vec![12.0, 40.0, 55.0, 61.0], + daily, + ..Data::default() + }; + for w in [20usize, 40, 80, 200] { + let rows = tab(&d, w, 40, &cfg, &p); + let plain = rows.join("\n"); + for want in ["QUOTA", "TOTALS", "OUTPUT RATE", "TOKENS / DAY", "SUBSCRIPTION"] { + assert!(plain.contains(want), "{} missing at width {}", want, w); + } + } + // Wide, the feature is spelled out; narrow, its last segment carries + // it, because the alternative is a bar with nowhere to be drawn. + let wide = codex_quota(&d, 200, &p).join("\n"); + assert!(wide.contains("GPT-5.3-Codex-Spark"), "{}", wide); + let narrow = codex_quota(&d, 60, &p).join("\n"); + assert!(!narrow.contains("GPT-5.3-Codex-Spark"), "{}", narrow); + assert!(narrow.contains("Spark"), "{}", narrow); + // With a named lane beside them, the account's own windows say what + // they cover rather than leaving "7d" to stand alone. + assert!(narrow.contains("overall"), "{}", narrow); + } + + #[test] + fn a_machine_with_no_rollouts_still_shows_the_account_quota() { + // The quota is the account's and is true whatever is on this disk, so + // an empty sessions directory hides the local half and nothing else. + let p = palette(); + let cfg = Config::default(); + let d = Data { + live: Some( + serde_json::from_str( + r#"{"plan_type":"pro","rate_limit":{"primary_window": + {"used_percent":26.0,"limit_window_seconds":604800}}}"#, + ) + .expect("a live reading"), + ), + ..Data::default() + }; + let rows = tab(&d, 90, 40, &cfg, &p).join(" "); + assert!(rows.contains("QUOTA"), "{}", rows); + assert!(rows.contains("No session rollouts"), "{}", rows); + assert!(!rows.contains("TOTALS"), "{}", rows); + } +} diff --git a/widgets/src/bin/usage/copilot.rs b/widgets/src/bin/usage/copilot.rs new file mode 100644 index 0000000..e0228fc --- /dev/null +++ b/widgets/src/bin/usage/copilot.rs @@ -0,0 +1,945 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! GitHub Copilot: the account's premium-request pool, and the local +//! session store. +//! +//! The two halves are independent: the quota is the account's and arrives +//! over the network, while the per-turn detail is this machine's and is +//! frequently empty. Either can be present without the other. + +use std::collections::HashMap; + +use chrono::{Datelike, TimeZone, Timelike, Utc}; +use rusqlite::{Connection, OpenFlags}; +use toys_core as tc; + +use crate::shared::*; +use crate::*; + +const COPILOT_USER_API: &str = "https://api.github.com/copilot_internal/user"; + +fn copilot_db() -> String { + under_home(".copilot/session-store.db") +} + +fn copilot_config() -> String { + under_home(".copilot/config.json") +} + +/// The account's own description of itself. Names are what the API +/// returns, shortened only where it repeats itself. +const COPILOT_FEATURES: &[(&str, &str)] = &[ + ("chat_enabled", "chat"), + ("cli_enabled", "cli"), + ("is_mcp_enabled", "mcp"), + ("cli_remote_control_enabled", "remote control"), + ("cloud_session_storage_enabled", "cloud sessions"), + ("copilot_app_enabled", "app"), + ("editor_preview_features_enabled", "editor previews"), + ("copilotignore_enabled", "copilotignore"), +]; + +/// The per-turn aggregates the session store keeps, summed. +#[derive(Clone, Default)] +pub struct Spent { + input: f64, + output: f64, + cache: f64, + reasoning: f64, + nano_aiu: f64, + ttft: f64, + itl: f64, + ms: f64, +} + +/// Live quota, plus whatever the local session store has recorded. +#[derive(Clone, Default)] +pub struct Data { + /// The /copilot_internal/user response, when the request worked. + live: Option<serde_json::Value>, + /// Why it did not, when it did not. + live_why: String, + sessions: i64, + events: i64, + /// None until an event has been read - a locked store and an empty one + /// both leave this unset, and `why` says which it was. + usage: Option<Spent>, + /// day -> model -> tokens by priced kind, from the session store. + daily: HashMap<String, HashMap<String, Tokens>>, + /// (model, turns, output tokens), busiest first. + models: Vec<(String, i64, f64)>, + why: String, +} + +/// The span a monthly quota covers, worked back from its reset, as its +/// label and its length in seconds. +/// +/// Copilot states when the quota resets but never how long the window is. +/// It can be derived, but only safely when the reset lands on midnight UTC +/// on the first of a month - which is what a calendar-month cycle looks +/// like, and what this account shows. Anything else and the window is not +/// known, so nothing is claimed about it: no label, and no pace figure, +/// because a pace against a guessed window is a fabricated number. +/// +/// Label and length come from the one function so the refusal cannot +/// diverge between them. usage.py refuses only for the label and derives +/// the pace span unconditionally, which contradicts its own comment here. +pub fn quota_window(reset_ts: Option<f64>) -> Option<(String, f64)> { + let stamp = reset_ts?; + if stamp <= 0.0 { + return None; + } + let end = Utc.timestamp_opt(stamp as i64, 0).single()?; + if (end.day(), end.hour(), end.minute(), end.second()) != (1, 0, 0, 0) { + return None; + } + let start = if end.month() == 1 { + Utc.with_ymd_and_hms(end.year() - 1, 12, 1, 0, 0, 0) + } else { + Utc.with_ymd_and_hms(end.year(), end.month() - 1, 1, 0, 0, 0) + } + .single()?; + Some(( + format!( + "{} {} → {} {}", + start.day(), + MONTHS[start.month0() as usize], + end.day(), + MONTHS[end.month0() as usize] + ), + stamp - start.timestamp() as f64, + )) +} + +/// The OAuth token Copilot's CLI keeps in ~/.copilot/config.json. +/// +/// That file is JSON with `//` comments on top, which a JSON parser +/// refuses, so the comments come off first. Keyed by host and login, +/// because one machine can be signed in to github.com and an Enterprise +/// host at once. +pub fn copilot_token() -> Option<String> { + token_in(&std::fs::read_to_string(copilot_config()).ok()?) +} + +fn token_in(raw: &str) -> Option<String> { + let clean: String = raw + .lines() + .map(|line| if line.trim_start().starts_with("//") { "" } else { line }) + .collect::<Vec<_>>() + .join("\n"); + let parsed: serde_json::Value = serde_json::from_str(&clean).ok()?; + parsed["copilotTokens"] + .as_object() + .into_iter() + .flatten() + .find_map(|(_, tok)| tok.as_str().filter(|t| !t.is_empty()).map(String::from)) +} + +/// Entitlement and quota, from the endpoint the Copilot CLI itself uses. +/// +/// This is where Copilot's remaining quota actually lives. The session +/// store records what was spent per turn and is empty on plenty of +/// machines; this is the account's standing, and it answers the only +/// question a limit pane is really asked. +fn copilot_live() -> Option<serde_json::Value> { + let Some(tok) = copilot_token() else { + return Some(serde_json::json!({"why": "no token in ~/.copilot/config.json"})); + }; + // Which of the two went wrong matters: blaming the config file for a + // dropped connection sends the reader to edit a file that is fine. + let failed = |e: String| { + let short: String = e.chars().take(40).collect(); + Some(serde_json::json!({"why": format!("quota request failed: {}", short)})) + }; + let auth = format!("token {}", tok); + match tc::get( + COPILOT_USER_API, + &[ + ("Authorization", &auth), + ("User-Agent", "terminal-toys"), + ("Accept", "application/json"), + ], + 20, + ) { + Ok(body) => match serde_json::from_str::<serde_json::Value>(&body) { + Ok(data) => Some(serde_json::json!({"data": data})), + Err(e) => failed(e.to_string()), + }, + Err(e) => failed(e), + } +} + +pub fn read(caches: &mut Caches, _cfg: &Config) -> Data { + let mut d = Data::default(); + if let Some(got) = cached(caches, "copilot", LIVE_TTL, copilot_live) { + if got["data"].is_object() { + d.live = Some(got["data"].clone()); + } + d.live_why = text(&got, "why"); + } + read_store_at(&copilot_db(), &mut d); + d +} + +/// The session store, read without disturbing it. +/// +/// Read-only is not decoration: this is a live agent's working state. +/// Any failure - missing, locked, corrupt - leaves the counts unset and +/// says why, because those numbers are then unknown, not zero. +fn read_store_at(path: &str, d: &mut Data) { + if !std::path::Path::new(path).exists() { + d.why = "no session store".into(); + return; + } + let opened = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .and_then(|con| scan_store(&con, d)); + if let Err(e) = opened { + d.why = e.to_string().chars().take(40).collect(); + } +} + +fn scan_store(con: &Connection, d: &mut Data) -> rusqlite::Result<()> { + d.sessions = con.query_row("select count(*) from sessions", [], |r| r.get(0))?; + // Every sum() comes back NULL on an empty table, so each is read as an + // Option rather than trusted to be a number. + type Sums = ( + i64, + Option<f64>, + Option<f64>, + Option<f64>, + Option<f64>, + Option<f64>, + Option<f64>, + Option<f64>, + Option<f64>, + ); + let row: Sums = con.query_row( + "select count(*), sum(input_tokens), sum(output_tokens), \ + sum(cache_read_tokens), sum(reasoning_tokens), \ + sum(total_nano_aiu), avg(time_to_first_token_ms), \ + avg(inter_token_latency_ms), sum(duration_ms) \ + from assistant_usage_events", + [], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + )) + }, + )?; + d.events = row.0; + if d.events == 0 { + return Ok(()); + } + d.usage = Some(Spent { + input: row.1.unwrap_or(0.0), + output: row.2.unwrap_or(0.0), + cache: row.3.unwrap_or(0.0), + reasoning: row.4.unwrap_or(0.0), + nano_aiu: row.5.unwrap_or(0.0), + ttft: row.6.unwrap_or(0.0), + itl: row.7.unwrap_or(0.0), + ms: row.8.unwrap_or(0.0), + }); + // localtime, because claude, codex, cursor and grok all bucket by the + // reader's own midnight and day_calendar draws them on one wall under + // the same headings. created_at is stored with a trailing Z, so plain + // date() is the UTC day: east of Greenwich, an evening turn filed + // under yesterday. + let mut daily = con.prepare( + "select date(created_at, 'localtime'), model, sum(input_tokens), \ + sum(output_tokens), sum(cache_read_tokens), \ + sum(cache_write_tokens) from assistant_usage_events \ + group by 1, 2", + )?; + let found = daily.query_map([], |r| { + Ok(( + r.get::<_, Option<String>>(0)?, + r.get::<_, Option<String>>(1)?, + r.get::<_, Option<f64>>(2)?, + r.get::<_, Option<f64>>(3)?, + r.get::<_, Option<f64>>(4)?, + r.get::<_, Option<f64>>(5)?, + )) + })?; + for got in found { + let (day, model, i_tok, o_tok, cr, cw) = got?; + // A NULL created_at has no day. usage.py keys it "None", which + // sorts above every real date and would count into today forever. + let Some(day) = day.filter(|x| !x.is_empty()) else { + continue; + }; + let bucket = d + .daily + .entry(day) + .or_default() + .entry(model.unwrap_or_default()) + .or_insert_with(empty_tokens); + *bucket.get_mut("input").unwrap() += i_tok.unwrap_or(0.0); + *bucket.get_mut("output").unwrap() += o_tok.unwrap_or(0.0); + *bucket.get_mut("cache_read").unwrap() += cr.unwrap_or(0.0); + *bucket.get_mut("cache_write").unwrap() += cw.unwrap_or(0.0); + } + let mut models = con.prepare( + "select model, count(*), sum(output_tokens), \ + sum(input_tokens), sum(cache_read_tokens), \ + sum(cache_write_tokens) \ + from assistant_usage_events group by model \ + order by 3 desc limit 6", + )?; + let found = models.query_map([], |r| { + Ok(( + r.get::<_, Option<String>>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, Option<f64>>(2)?, + )) + })?; + for got in found { + let (model, turns, out_tok) = got?; + d.models.push(( + model.filter(|m| !m.is_empty()).unwrap_or_else(|| "?".into()), + turns, + out_tok.unwrap_or(0.0), + )); + } + Ok(()) +} + +/// Every quota this agent publishes, for the summary screen. +/// +/// window_secs carries quota_window's refusal: None whenever the reset is +/// not midnight UTC on the first of a month, and the summary then draws no +/// pace mark rather than a wrong one. +pub fn lanes(d: &Data) -> Vec<Lane> { + let Some(live) = d.live.as_ref() else { + return Vec::new(); + }; + let stamp = iso_epoch(&text(live, "quota_reset_date_utc")); + let span = quota_window(stamp).map(|(_, secs)| secs); + let mut out = Vec::new(); + for (key, snap) in live["quota_snapshots"].as_object().into_iter().flatten() { + // An enterprise seat is why two of the three pools come back + // unlimited; a pool with no denominator has no percentage to rank. + if snap["unlimited"].as_bool().unwrap_or(false) || snap["percent_remaining"].is_null() { + continue; + } + out.push(Lane { + label: key.replace('_', " ").replace("interactions", "reqs"), + pct: 100.0 - num(snap, "percent_remaining"), + window_secs: span, + reset: stamp, + stale: false, + projected: false, + apart: false, + }); + } + out +} + +/// Which subscription this quota belongs to, and since when. +/// +/// An enterprise seat is why two of the three pools come back unlimited, +/// and assigned_date is the only field saying how long it has been so. +pub fn copilot_plan_rows(live: &serde_json::Value, w: usize, p: &Palette) -> Vec<String> { + let mut pairs: Vec<(String, String)> = Vec::new(); + let assigned = text(live, "assigned_date"); + let day = iso_day(&assigned); + if let Some(since) = iso_epoch(&assigned) { + if !day.is_empty() { + pairs.push(("seat since".into(), format!("{} · {} ago", day, ago(since)))); + } + } + let orgs: Vec<String> = live["organization_list"] + .as_array() + .into_iter() + .flatten() + .filter_map(|o| { + [text(o, "name"), text(o, "login")].into_iter().find(|s| !s.is_empty()) + }) + .collect(); + if !orgs.is_empty() { + pairs.push(("organisation".into(), orgs.join(", "))); + } + for (label, key) in [("account", "login"), ("sku", "access_type_sku")] { + let value = text(live, key); + if !value.is_empty() { + pairs.push((label.into(), value)); + } + } + if live["token_based_billing"].as_bool().unwrap_or(false) { + pairs.push(("billing".into(), "token-based".into())); + } + let on: Vec<String> = COPILOT_FEATURES + .iter() + .filter(|(flag, _)| live[*flag].as_bool().unwrap_or(false)) + .map(|(_, name)| name.to_string()) + .collect(); + plan_rows( + &text(live, "copilot_plan"), + &pairs, + w, + if live["can_upgrade_plan"].as_bool().unwrap_or(false) { "upgradeable" } else { "" }, + Some(("enabled", &on)), + "", + p, + ) +} + +pub fn copilot_metered(d: &Data, w: usize, cfg: &Config, p: &Palette) -> Vec<String> { + metered_rows( + &[ + ("today".to_string(), claude::window_models(&d.daily, 1)), + ("30 days".to_string(), claude::window_models(&d.daily, 30)), + ], + w, + "", + "copilot", + "this machine", + "Counted from the local session store. Copilot in an editor, on \ + another machine or on github.com is not in here.", + cfg, + p, + ) +} + +/// 1234567 -> "1,234,567". Entitlements run to thousands of requests, and +/// nobody counts an unbroken run of digits. +fn commas(n: i64) -> String { + let raw = n.abs().to_string(); + let mut out = String::new(); + for (i, ch) in raw.chars().enumerate() { + if i > 0 && (raw.len() - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + if n < 0 { + format!("-{}", out) + } else { + out + } +} + +fn copilot_tab(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let null = serde_json::Value::Null; + let live = d.live.as_ref().unwrap_or(&null); + let mut rows: Vec<String> = Vec::new(); + let snaps = live["quota_snapshots"].as_object().cloned().unwrap_or_default(); + if !snaps.is_empty() { + // A monthly quota reset arrives as a bare date; days remaining is + // the form every other tab here uses, and the one anyone reads. + // quota_reset_date_utc, not quota_reset_date: the bare date carries + // no zone, so it parses as local midnight and the countdown drifts + // by the machine's UTC offset. Zero on the machine this was written + // on, which is exactly why it would have gone unnoticed there. + let stamp = iso_epoch(&text(live, "quota_reset_date_utc")); + let mut when = String::new(); + if let Some(stamp) = stamp { + let left = stamp - now(); + // "resets today" was already better than a truncated 0d, and + // the hours it was rounding away are better still. + when = if left > 0.0 { + format!("resets in {}", left_span(left)) + } else { + "resetting".into() + }; + } + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── QUOTA ── ".into()), + (p.ok.as_str(), "live".into()), + (p.dim.as_str(), " · account-wide".into()), + ], + w - 1, + )); + // The reset sits with the window rather than on the header: they + // are two halves of the same cycle, and the header had no room for + // it. The window is claimed only when quota_window could derive it. + let window = quota_window(stamp); + let span_label = window.as_ref().map(|(label, _)| label.clone()); + let span_secs = window.map(|(_, secs)| secs); + if span_label.is_some() || !when.is_empty() { + rows.push(tc::seg( + &[ + (p.dim.as_str(), " window ".into()), + (p.txt.as_str(), span_label.clone().unwrap_or_else(|| "—".into())), + ( + p.dim.as_str(), + if span_label.is_some() { " · monthly · ".into() } else { " ".into() }, + ), + (p.dim.as_str(), when), + ], + w - 1, + )); + } + let label_w = snaps.keys().map(|k| k.chars().count()).max().unwrap_or(0).max(9); + let mut keys: Vec<&String> = snaps.keys().collect(); + keys.sort_by_key(|k| (snaps[*k]["unlimited"].as_bool().unwrap_or(false), (*k).clone())); + for key in keys { + let q = &snaps[key]; + let name = tc::pad(&key.replace('_', " "), label_w); + if q["unlimited"].as_bool().unwrap_or(false) { + // No denominator, so no bar. An unlimited pool drawn as an + // empty gauge invents a limit that was explicitly denied. + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {} ", name)), + (p.ok.as_str(), "unlimited".into()), + ], + w - 1, + )); + continue; + } + let ent = num(q, "entitlement"); + // percent_remaining is what the API gives; every other tab here + // shows what is spent, and red belongs at the empty end. + let pct = 100.0 - num(q, "percent_remaining"); + let used = (pct / 100.0).clamp(0.0, 1.0); + let room = ((w as i64) - 38 - label_w as i64).max(8) as usize; + let hue = agent_hue("copilot"); + let mut line: Vec<(String, String)> = vec![(p.dim.clone(), format!(" {} ", name))]; + line.extend(paced_bar(used, elapsed_of(span_secs, stamp), room, hue, p)); + line.push((pct_colour(pct, hue, p), pct_text(pct))); + line.push(pace_cell(lead(pct, span_secs, stamp), p)); + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + // A pool can carry its own reset, in which case it is not on + // the account-wide cycle in the header and has to say so + // itself. + let own = num(q, "quota_reset_at"); + let mut own_when = String::new(); + if own > 0.0 && (own - stamp.unwrap_or(0.0)).abs() > 3600.0 { + let left = own - now(); + own_when = if left > 0.0 { + format!(" resets in {}", left_span(left)) + } else { + " resetting".into() + }; + } + if ent > 0.0 { + let mut line: Vec<(String, String)> = vec![ + (p.dim.clone(), format!(" {} ", " ".repeat(label_w))), + (p.txt.clone(), commas(num(q, "credits_used") as i64)), + (p.dim.clone(), " of ".into()), + (p.txt.clone(), commas(ent as i64)), + ]; + // No room for the remainder on a narrow pane. + if (label_w as i64) + 34 <= (w as i64) - 1 { + line.push((p.dim.clone(), " · ".into())); + line.push((p.txt.clone(), commas(num(q, "remaining") as i64))); + line.push((p.dim.clone(), " left".into())); + } + let over = num(q, "overage_count"); + line.push(( + p.warn.clone(), + if over > 0.0 { format!(" {} over", over as i64) } else { String::new() }, + )); + line.push((p.dim.clone(), own_when)); + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + } + rows.push(String::new()); + } else if !d.live_why.is_empty() { + rows.push(tc::seg( + &[(p.warn.as_str(), format!(" no quota: {}", d.live_why))], + w - 1, + )); + rows.push(String::new()); + } + + match d.usage.as_ref() { + Some(u) => { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── SPENT ── ".into()), + ( + p.dim.as_str(), + format!("{} turns across {} sessions", d.events, d.sessions), + ), + ], + w - 1, + )); + let cells: Vec<(&str, String, &str)> = vec![ + ("input tokens", big_num(u.input), p.txt.as_str()), + ("output tokens", big_num(u.output), p.agent.as_str()), + ("cache read", big_num(u.cache), p.dim.as_str()), + ("reasoning tokens", big_num(u.reasoning), p.txt.as_str()), + // total_nano_aiu is billionths of an AI unit. + ("AI units", format!("{:.3}", u.nano_aiu / 1e9), p.txt.as_str()), + ("time generating", span_ms(u.ms), p.dim.as_str()), + ]; + let label_w = cells.iter().map(|c| c.0.chars().count()).max().unwrap_or(0); + let ncols: usize = + if (w as i64 - 2) / 2 - label_w as i64 - 3 >= 8 { 2 } else { 1 }; + let cw = (w - 2) / ncols; + let val_w = (cw as i64 - label_w as i64 - 3).max(5) as usize; + for chunk in cells.chunks(ncols) { + let mut line: Vec<(String, String)> = vec![(tc::RST.to_string(), " ".into())]; + for (lab, value, colour) in chunk { + line.push((p.dim.clone(), format!(" {} ", tc::pad(lab, label_w)))); + line.push((colour.to_string(), tc::pad(value, val_w))); + } + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + rows.push(String::new()); + // The one agent that measures this rather than leaving it to be + // inferred from timestamps, which is why it is stated flatly. + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── LATENCY ── ".into()), + (p.dim.as_str(), "measured by Copilot, not inferred".into()), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " first token ".into()), + (p.txt.as_str(), format!("{:.0} ms", u.ttft)), + (p.dim.as_str(), " between tokens ".into()), + (p.txt.as_str(), format!("{:.1} ms", u.itl)), + ], + w - 1, + )); + rows.push(String::new()); + if !d.models.is_empty() { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── BY MODEL ── ".into()), + (p.dim.as_str(), "output tokens".into()), + ], + w - 1, + )); + for (model, turns, out_tok) in &d.models { + rows.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {}", tc::pad(model, 28))), + (p.dim.as_str(), format!("{:5} turns ", turns)), + (p.agent.as_str(), big_num(*out_tok)), + ], + w - 1, + )); + } + } + } + // usage.py says "no local sessions" here whatever `why` holds, so a + // locked store read as an empty one. Unreadable and empty are + // different facts and get different sentences. + None if !d.why.is_empty() && d.why != "no session store" => { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── SPENT ── ".into()), + (p.warn.as_str(), "session store unreadable".into()), + ], + w - 1, + )); + rows.push(String::new()); + rows.extend(no_local( + &format!( + "The session store could not be read ({}), so what it holds is \ + unknown - not zero.", + d.why + ), + "", + w, + p, + )); + } + None => { + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── SPENT ── ".into()), + (p.dim.as_str(), "no local sessions".into()), + ], + w - 1, + )); + rows.push(String::new()); + rows.extend(no_local( + "Nothing recorded in the local session store yet.", + run_hint("copilot"), + w, + p, + )); + } + } + rows +} + +/// The whole tab: the quota, what this machine spent, what that cost, and +/// which subscription the percentages are percentages of. +pub fn tab(d: &Data, w: usize, _h: usize, cfg: &Config, p: &Palette) -> Vec<String> { + let body = add_section(copilot_tab(d, w, p), copilot_metered(d, w, cfg, p)); + let null = serde_json::Value::Null; + add_section(body, copilot_plan_rows(d.live.as_ref().unwrap_or(&null), w, p)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn utc_epoch(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> f64 { + Utc.with_ymd_and_hms(y, mo, d, h, mi, s).unwrap().timestamp() as f64 + } + + #[test] + fn a_reset_at_midnight_utc_on_the_first_yields_the_window() { + let (label, secs) = quota_window(Some(utc_epoch(2026, 8, 1, 0, 0, 0))).unwrap(); + assert_eq!(label, "1 Jul → 1 Aug"); + // Computed rather than a literal, so the test cannot encode the + // same arithmetic slip as the code. + let expect = utc_epoch(2026, 8, 1, 0, 0, 0) - utc_epoch(2026, 7, 1, 0, 0, 0); + assert_eq!(secs, expect); + assert_eq!(secs, 31.0 * 86400.0); + } + + #[test] + fn a_january_reset_reaches_back_into_december() { + let (label, secs) = quota_window(Some(utc_epoch(2026, 1, 1, 0, 0, 0))).unwrap(); + assert_eq!(label, "1 Dec → 1 Jan"); + assert_eq!(secs, utc_epoch(2026, 1, 1, 0, 0, 0) - utc_epoch(2025, 12, 1, 0, 0, 0)); + } + + #[test] + fn a_march_reset_gets_februarys_shorter_window() { + let (_, secs) = quota_window(Some(utc_epoch(2026, 3, 1, 0, 0, 0))).unwrap(); + assert_eq!(secs, 28.0 * 86400.0); + } + + #[test] + fn a_reset_anywhere_but_midnight_on_the_first_is_refused() { + // Not the first of a month. + assert!(quota_window(Some(utc_epoch(2026, 8, 15, 0, 0, 0))).is_none()); + // The first, but not midnight. + assert!(quota_window(Some(utc_epoch(2026, 8, 1, 7, 0, 0))).is_none()); + assert!(quota_window(Some(utc_epoch(2026, 8, 1, 0, 30, 0))).is_none()); + assert!(quota_window(Some(utc_epoch(2026, 8, 1, 0, 0, 30))).is_none()); + } + + #[test] + fn no_reset_means_no_window() { + assert!(quota_window(None).is_none()); + // Epoch zero is 1 Jan 1970 00:00 UTC, which would pass the shape + // check while meaning "no timestamp". + assert!(quota_window(Some(0.0)).is_none()); + } + + fn live_with_reset(reset: &str) -> Data { + let mut d = Data::default(); + d.live = Some(serde_json::json!({ + "quota_reset_date_utc": reset, + "quota_snapshots": { + "premium_interactions": {"unlimited": false, "percent_remaining": 25.0}, + "chat": {"unlimited": true}, + "completions": {"unlimited": false} + } + })); + d + } + + #[test] + fn lanes_skip_unlimited_pools_and_pools_with_no_percentage() { + let got = lanes(&live_with_reset("2026-09-01T00:00:00Z")); + assert_eq!(got.len(), 1); + assert_eq!(got[0].label, "premium reqs"); + assert_eq!(got[0].pct, 75.0); + } + + #[test] + fn lanes_carry_the_window_only_when_it_could_be_derived() { + // Midnight UTC on the first: a calendar month, so the window is + // claimed - August's 31 days. + let got = lanes(&live_with_reset("2026-09-01T00:00:00Z")); + assert_eq!(got[0].window_secs, Some(31.0 * 86400.0)); + assert!(got[0].reset.is_some()); + // Mid-month: the window is not known, so no pace mark is drawn - + // but the lane itself and its reset are still real. + let got = lanes(&live_with_reset("2026-09-15T00:00:00Z")); + assert_eq!(got.len(), 1); + assert_eq!(got[0].window_secs, None); + assert!(got[0].reset.is_some()); + } + + fn store_with(rows: &[&str]) -> Connection { + let con = Connection::open_in_memory().unwrap(); + con.execute_batch( + "create table sessions (id integer primary key); \ + create table assistant_usage_events ( \ + created_at text, model text, input_tokens integer, \ + output_tokens integer, cache_read_tokens integer, \ + cache_write_tokens integer, reasoning_tokens integer, \ + total_nano_aiu integer, time_to_first_token_ms real, \ + inter_token_latency_ms real, duration_ms integer);", + ) + .unwrap(); + for values in rows { + con.execute_batch(&format!( + "insert into assistant_usage_events values ({});", + values + )) + .unwrap(); + } + con + } + + #[test] + fn a_turn_is_filed_under_the_day_it_was_here() { + // 20:00 UTC is already tomorrow anywhere east of Greenwich. The + // other five readers bucket by the reader's own midnight and + // day_calendar draws them all on one wall, so this one must too. + // Every other store test uses a mid-morning stamp, which falls on + // the same date in both zones and cannot tell them apart. + let con = store_with(&[ + "'2026-08-16T20:00:00Z', 'model-a', 100, 0, 0, 0, 0, 0, 0.0, 0.0, 0", + ]); + let mut d = Data::default(); + scan_store(&con, &mut d).expect("a readable store"); + let days: Vec<&String> = d.daily.keys().collect(); + assert_eq!(days.len(), 1, "{:?}", days); + let want = chrono::Local + .timestamp_opt( + chrono::DateTime::parse_from_rfc3339("2026-08-16T20:00:00Z") + .expect("a fixed stamp") + .timestamp(), + 0, + ) + .single() + .expect("a local time") + .format("%Y-%m-%d") + .to_string(); + assert_eq!(days[0], &want, "filed under the wrong day"); + } + + #[test] + fn the_session_store_sums_read_whole() { + let con = store_with(&[ + "'2026-08-20T10:00:00Z', 'model-a', 100, 200, 50, 10, 5, 1500000000, 500.0, 20.0, 3000", + "'2026-08-21T11:00:00Z', 'model-b', 10, 999, 40, 0, 0, 500000000, 300.0, 10.0, 2000", + ]); + con.execute_batch("insert into sessions values (1); insert into sessions values (2);") + .unwrap(); + let mut d = Data::default(); + scan_store(&con, &mut d).unwrap(); + assert_eq!(d.sessions, 2); + assert_eq!(d.events, 2); + let u = d.usage.as_ref().unwrap(); + assert_eq!(u.input, 110.0); + assert_eq!(u.output, 1199.0); + assert_eq!(u.cache, 90.0); + assert_eq!(u.reasoning, 5.0); + assert_eq!(u.nano_aiu, 2_000_000_000.0); + assert_eq!(u.ttft, 400.0); + assert_eq!(u.itl, 15.0); + assert_eq!(u.ms, 5000.0); + let day = &d.daily["2026-08-20"]["model-a"]; + assert_eq!(day["input"], 100.0); + assert_eq!(day["output"], 200.0); + assert_eq!(day["cache_read"], 50.0); + assert_eq!(day["cache_write"], 10.0); + assert_eq!(day["cache_write_1h"], 0.0); + // Busiest model first, by output tokens. + assert_eq!(d.models[0], ("model-b".to_string(), 1, 999.0)); + assert_eq!(d.models[1], ("model-a".to_string(), 1, 200.0)); + } + + #[test] + fn null_sums_are_read_as_options_not_trusted_as_numbers() { + // One event whose every column is NULL: count(*) is 1, and every + // sum() and avg() beside it is NULL rather than a number. + let con = store_with(&[ + "NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL", + ]); + let mut d = Data::default(); + scan_store(&con, &mut d).unwrap(); + assert_eq!(d.events, 1); + let u = d.usage.as_ref().unwrap(); + assert_eq!(u.input, 0.0); + assert_eq!(u.ttft, 0.0); + // date(NULL) is NULL: no day to file it under, so it is skipped + // rather than keyed as a fake day that counts into today forever. + assert!(d.daily.is_empty()); + assert_eq!(d.models[0].0, "?"); + } + + #[test] + fn an_empty_store_leaves_the_spending_unset() { + let con = store_with(&[]); + let mut d = Data::default(); + scan_store(&con, &mut d).unwrap(); + assert_eq!(d.events, 0); + assert!(d.usage.is_none()); + assert!(d.models.is_empty()); + } + + #[test] + fn a_missing_store_is_unknown_not_zero() { + let mut d = Data::default(); + read_store_at("/nonexistent-invented-path/session-store.db", &mut d); + assert_eq!(d.why, "no session store"); + assert!(d.usage.is_none()); + assert_eq!(d.events, 0); + } + + #[test] + fn an_unreadable_store_fails_soft_with_a_reason() { + // An invented file that is not SQLite at all; the reader must come + // back with a why rather than a panic or a plausible zero. + let path = std::env::temp_dir().join(format!("toys-copilot-test-{}.db", std::process::id())); + std::fs::write(&path, "not a database, on purpose").unwrap(); + let mut d = Data::default(); + read_store_at(path.to_str().unwrap(), &mut d); + std::fs::remove_file(&path).ok(); + assert!(!d.why.is_empty()); + assert_ne!(d.why, "no session store"); + assert!(d.usage.is_none()); + } + + #[test] + fn the_token_survives_the_configs_comment_header() { + let raw = "// GitHub Copilot CLI configuration\n\ + // managed by the CLI, do not edit\n\ + {\n \"copilotTokens\": {\n \"github.example\": \"tok_invented_0000\"\n },\n\ + \"theme\": \"auto\"\n}\n"; + assert_eq!(token_in(raw), Some("tok_invented_0000".into())); + } + + #[test] + fn an_empty_or_missing_token_is_none_rather_than_blank() { + assert_eq!(token_in("{\"copilotTokens\": {\"github.example\": \"\"}}"), None); + assert_eq!(token_in("{\"copilotTokens\": {}}"), None); + assert_eq!(token_in("{}"), None); + assert_eq!(token_in("not json at all"), None); + } + + #[test] + fn commas_group_digits_the_way_a_person_counts_them() { + assert_eq!(commas(0), "0"); + assert_eq!(commas(999), "999"); + assert_eq!(commas(1000), "1,000"); + assert_eq!(commas(1234567), "1,234,567"); + } +} diff --git a/widgets/src/bin/usage/cursor.rs b/widgets/src/bin/usage/cursor.rs new file mode 100644 index 0000000..95592ef --- /dev/null +++ b/widgets/src/bin/usage/cursor.rs @@ -0,0 +1,1347 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Cursor: the three lanes its own Usage view shows, the spend its server +//! prices, and the tracking database that says how much code it wrote. +//! +//! The one agent whose metered section needs no rate card: the API states +//! both what Cursor charges against the plan and what the same traffic +//! costs at vendor list, so nothing here is estimated. The authorship +//! counts answer a different question - how much code the agent wrote, +//! not what it cost - and the tab keeps the two apart. + +use std::collections::HashMap; + +use chrono::{Datelike, Duration as Days, Local, NaiveDate, TimeZone}; +use rusqlite::{Connection, OpenFlags}; +use toys_core as tc; + +use crate::shared::*; +use crate::*; + +/// The Connect endpoint cursor-agent's own Usage view calls. Not the +/// documented cursor.com dashboard API - that one wants a browser cookie +/// and returns 401 to anything this machine has. The CLI talks to +/// aiserver.v1.DashboardService with the bearer token it stores beside its +/// config, which is the credential this reuses. +const CURSOR_RPC: &str = "https://api2.cursor.sh/aiserver.v1.DashboardService/"; + +/// The lanes in the order cursor-agent's Usage view lists them: each one's +/// label, the field it reads, and how far up Cursor's own colour it sits. +/// +/// One tint per lane rather than three unrelated hues. These are +/// categories, not one gauge, so a green-to-red severity ramp would imply +/// a relationship between them that does not exist - and each bar is +/// labelled and carries its own percentage, so the colour is decoration. +/// Tints of Cursor's own colour stay distinguishable while reading as +/// Cursor's, which three borrowed colours never did. +const CURSOR_LANES: &[(&str, &str, f64)] = &[ + ("included", "totalPercentUsed", 1.0), + ("auto", "autoPercentUsed", 0.80), + ("api", "apiPercentUsed", 0.62), +]; + +/// Grok Bot's weekly included allowance, which Cursor's own API calls +/// "sand" - the name that shows up in the spend breakdown as +/// `sand-default` and `sand-automation`. +/// +/// It is not one of the three plan lanes and does not share their window: +/// the plan lanes run to the monthly billing cycle, this one resets +/// weekly on a date the response states. Drawing it against the cycle's +/// clock would put its pace marker in the wrong place every time. +/// +/// Reached through the same RPC service and the same bearer token as the +/// three plan lanes. Cursor's own dashboard calls this over the website as +/// `POST /api/dashboard/get-sand-usage-status`, which is cookie-only and +/// answers the app's token with a redirect to the login provider - so the +/// obvious reading is that this needs a browser session. It does not: the +/// Connect service exposes the same call, and the token already on disk is +/// enough. Worth stating, because the website's route is the documented +/// one and following it would have put a credential in config.json for no +/// reason at all. +const SAND_METHOD: &str = "GetSandUsageStatus"; +const SAND_KEY: &str = "cursor:sand"; +/// The allowance is weekly, so this need not be brisk. +const SAND_TTL: f64 = 900.0; + +/// The events RPC's own ceiling per request. +const EVENT_PAGE: usize = 1000; +/// Enough pages to reach past any sane window. +const EVENT_PAGES: usize = 8; +/// Half an hour: thirty days of events is about five pages and eleven +/// seconds of paging, far too slow to repeat on a redraw. +const EVENTS_TTL: f64 = 1800.0; + +/// What Cursor publishes, and what its tracking database recorded here. +#[derive(Clone, Default)] +pub struct Data { + /// The tracking database was readable; the counts below mean something. + ok: bool, + why: String, + /// Absent rather than broken, so the fix is running the agent. + db_missing: bool, + /// GetCurrentPeriodUsage: the three lanes and the billing cycle. + live: Option<serde_json::Value>, + /// GetPlanInfo: the plan the percentages are percentages of. + plan: Option<serde_json::Value>, + /// The per-day summary folded out of GetFilteredUsageEvents. + events: Option<serde_json::Value>, + /// GetAggregatedUsageEvents: per-model cents over the window. + spend: Option<serde_json::Value>, + /// get-sand-usage-status: the Grok Bot weekly allowance, when a cookie + /// was configured and the account has one. + sand: Option<serde_json::Value>, + /// Why it is not showing, when a cookie was configured and it still is + /// not. Empty when none was configured - that is not a failure. + sand_why: String, + hashes: i64, + conversations: i64, + models: i64, + by_model: Vec<(String, i64)>, + commits: i64, + lines: i64, + human_lines: i64, + /// Milliseconds, from the newest tracked edit. + last: Option<f64>, +} + +/// A number whether it arrived as one or as a string. +/// +/// Connect writes int64 as JSON strings, so reading the billing cycle's +/// timestamps with a plain as_f64 would produce a wrong window with no +/// error - the kind of silent zero this widget exists to avoid. +fn loose(v: &serde_json::Value) -> Option<f64> { + v.as_f64().or_else(|| v.as_str()?.trim().parse().ok()) +} + +/// Thousands separators: "1,482,113 lines" is readable, "1482113" is not. +fn commas(n: i64) -> String { + let digits = n.abs().to_string(); + let mut out = String::new(); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + if n < 0 { + format!("-{}", out) + } else { + out + } +} + +fn cursor_token() -> Option<String> { + let auth = read_json(&under_home(".config/cursor/auth.json"))?; + let tok = text(&auth, "accessToken"); + if tok.is_empty() { + None + } else { + Some(tok) + } +} + +/// One Connect-style RPC to the dashboard service. +/// +/// Undocumented and versioned only by the CLI bundle it was read out of, +/// so every failure is silent and the tab simply falls back to whatever +/// else it has. The bearer token rides in a header that post_json feeds to +/// curl on its standard input, never in an argument - /proc/<pid>/cmdline +/// is world-readable. +fn cursor_rpc(method: &str, body: &serde_json::Value) -> Option<serde_json::Value> { + let tok = cursor_token()?; + let bearer = format!("Bearer {}", tok); + post_json( + &format!("{}{}", CURSOR_RPC, method), + &[ + ("Authorization", bearer.as_str()), + ("Content-Type", "application/json"), + ("Connect-Protocol-Version", "1"), + ("User-Agent", "terminal-toys"), + ], + &body.to_string(), + 25, + ) +} + +/// Plan usage, from the endpoint cursor-agent's own Usage view calls. +fn cursor_live() -> Option<serde_json::Value> { + cursor_rpc("GetCurrentPeriodUsage", &serde_json::json!({})) +} + +/// Which Cursor plan the percentages are percentages of. +/// +/// GetPlanInfo is where the plan's name and price live - the usage call +/// carries neither, and its $400 limit is meaningless without knowing that +/// is what Ultra includes. +fn cursor_plan() -> Option<serde_json::Value> { + cursor_rpc("GetPlanInfo", &serde_json::json!({})) +} + +/// The Grok Bot allowance, or why it could not be read. +/// +/// Best-effort by contract: every failure here has to leave Cursor's three +/// monthly bars exactly as they were, because this is an extra lane and not +/// a precondition for the rest of the tab. +fn sand_status() -> Result<serde_json::Value, String> { + let tok = cursor_token().ok_or("no Cursor token on this disk")?; + let bearer = format!("Bearer {}", tok); + post_json_said( + &format!("{}{}", CURSOR_RPC, SAND_METHOD), + &[ + ("Authorization", bearer.as_str()), + ("Content-Type", "application/json"), + ("Connect-Protocol-Version", "1"), + ("User-Agent", "terminal-toys"), + ], + "{}", + 20, + ) +} + +/// The percentage, the window it covers, and when it resets - or nothing. +/// +/// Nothing is the right answer in two different situations, and neither is +/// an error: an account with no Bot allowance at all, and a response that +/// carries the flag but no percentage. Cursor states the first as +/// `hasNonZeroIncludedLimit`, and drawing a 0% bar for an account that was +/// never given the allowance would invent a limit that does not exist. +fn sand_lane(v: &serde_json::Value) -> Option<(f64, Option<f64>, Option<f64>)> { + if v["hasNonZeroIncludedLimit"].as_bool() != Some(true) { + return None; + } + let pct = loose(&v["usagePercent"])?; + let start = iso_epoch(&text(v, "currentPeriodStart")); + let reset = iso_epoch(&text(v, "nextResetTimestampUtc")); + // The window only means anything as a pair, and only forwards. + let secs = match (start, reset) { + (Some(s), Some(e)) if e > s => Some(e - s), + _ => None, + }; + Some((pct.clamp(0.0, 100.0), secs, reset)) +} + +/// Per-model tokens and real cost over a window. +/// +/// This is what the plan percentages are made of: which model spent the +/// money. totalCents is Cursor's own figure, not an estimate. +fn cursor_spend(days: i64) -> Option<serde_json::Value> { + let at = (now() * 1000.0) as i64; + cursor_rpc( + "GetAggregatedUsageEvents", + &serde_json::json!({ + "startDate": (at - days * 86_400_000).to_string(), + "endDate": at.to_string(), + }), + ) +} + +/// The per-day running totals that pages of raw events fold into. +#[derive(Default)] +struct Tally { + by_day: HashMap<String, f64>, + tokens_by_day: HashMap<String, f64>, + /// day -> model -> (cents, tokens). + by_day_model: HashMap<String, HashMap<String, (f64, f64)>>, + vendor_cents: f64, + tokens: f64, + counted: u64, +} + +/// Fold one page of events into the tally. +/// +/// Returns the oldest timestamp on the page, which is what tells the +/// caller a page has reached past the window - events older than the cut +/// are not counted, but their age is still the stop signal. +fn tally_events(events: &[serde_json::Value], cut: f64, t: &mut Tally) -> f64 { + let mut oldest = now(); + for e in events { + let Some(when) = loose(&e["timestamp"]).map(|ms| ms / 1000.0) else { + continue; + }; + oldest = oldest.min(when); + if when < cut { + continue; + } + let usage = &e["tokenUsage"]; + let cents = loose(&usage["totalCents"]).unwrap_or(0.0); + let n = loose(&usage["inputTokens"]).unwrap_or(0.0) + + loose(&usage["outputTokens"]).unwrap_or(0.0); + let Some(day) = Local + .timestamp_opt(when as i64, 0) + .single() + .map(|d| d.format("%Y-%m-%d").to_string()) + else { + continue; + }; + *t.by_day.entry(day.clone()).or_default() += cents; + *t.tokens_by_day.entry(day.clone()).or_default() += n; + let model = match text(e, "model") { + s if s.is_empty() => "?".to_string(), + s => s, + }; + let slot = t.by_day_model.entry(day).or_default().entry(model).or_default(); + slot.0 += cents; + slot.1 += n; + t.vendor_cents += cents; + t.tokens += n; + t.counted += 1; + } + oldest +} + +/// Per-day spend, from the raw events cursor.com's own dashboard uses. +/// +/// GetAggregatedUsageEvents totals by model and carries no timestamp at +/// all, so no calendar can be built from it. GetFilteredUsageEvents +/// returns the individual events - each with a timestamp, a model and its +/// cents - newest first, a thousand at a time. +/// +/// Paging stops as soon as a page reaches past the window, so the cost is +/// proportional to the window rather than to the account's whole history; +/// EVENT_PAGES caps it regardless. Held for half an hour by the caller, +/// because this is far too slow to repeat on a redraw. +fn cursor_events(days: i64) -> Option<serde_json::Value> { + let cut = now() - days as f64 * 86400.0; + let mut t = Tally::default(); + for page in 1..=EVENT_PAGES { + let got = cursor_rpc( + "GetFilteredUsageEvents", + &serde_json::json!({ "page": page, "pageSize": EVENT_PAGE }), + ); + let events: Vec<serde_json::Value> = got + .as_ref() + .and_then(|g| g["usageEventsDisplay"].as_array()) + .cloned() + .unwrap_or_default(); + if events.is_empty() { + break; + } + let oldest = tally_events(&events, cut, &mut t); + if oldest < cut || events.len() < EVENT_PAGE { + break; + } + } + if t.counted == 0 { + return None; + } + let mut by_day_model = serde_json::Map::new(); + for (day, models) in &t.by_day_model { + let mut inner = serde_json::Map::new(); + for (model, (cents, tokens)) in models { + inner.insert( + model.clone(), + serde_json::json!({ "cents": cents, "tokens": tokens }), + ); + } + by_day_model.insert(day.clone(), serde_json::Value::Object(inner)); + } + Some(serde_json::json!({ + "by_day": t.by_day, + "tokens_by_day": t.tokens_by_day, + "by_day_model": by_day_model, + "vendor_cents": t.vendor_cents, + "tokens": t.tokens, + "events": t.counted, + "days": days, + })) +} + +/// The authorship counts, straight out of the tracking database. +struct Tracking { + hashes: i64, + conversations: i64, + models: i64, + by_model: Vec<(String, i64)>, + commits: i64, + lines: i64, + human_lines: i64, + last: Option<f64>, +} + +fn read_tracking(con: &Connection) -> rusqlite::Result<Tracking> { + let (hashes, conversations, models) = con.query_row( + "select count(*), count(distinct conversationId), count(distinct model) \ + from ai_code_hashes", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + )?; + let mut stmt = con.prepare( + "select model, count(*) from ai_code_hashes group by model order by 2 desc limit 8", + )?; + let by_model = stmt + .query_map([], |r| { + let name: Option<String> = r.get(0)?; + Ok(( + match name { + Some(s) if !s.is_empty() => s, + _ => "?".to_string(), + }, + r.get::<_, i64>(1)?, + )) + })? + .collect::<rusqlite::Result<Vec<_>>>()?; + let (commits, lines, human_lines) = con.query_row( + "select count(*), sum(linesAdded), sum(humanLinesAdded) from scored_commits", + [], + |r| { + Ok(( + r.get::<_, i64>(0)?, + // sum() over no rows is NULL, and NULL here is a true zero - + // a machine that has scored nothing - not a failure to read. + r.get::<_, Option<i64>>(1)?.unwrap_or(0), + r.get::<_, Option<i64>>(2)?.unwrap_or(0), + )) + }, + )?; + let last: Option<f64> = + con.query_row("select max(timestamp) from ai_code_hashes", [], |r| r.get(0))?; + Ok(Tracking { + hashes, + conversations, + models, + by_model, + commits, + lines, + human_lines, + last, + }) +} + +pub fn read(caches: &mut Caches, _cfg: &Config) -> Data { + let mut d = Data::default(); + // The published sections do not depend on the local database, so a + // locked or missing file must not take the live quota down with it - + // which is what usage.py's sqlite-error branch does. + d.live = cached(caches, "cursor", LIVE_TTL, cursor_live); + d.plan = cached(caches, "cursor-plan", PLAN_TTL, cursor_plan); + d.events = cached(caches, "cursor-events", EVENTS_TTL, || cursor_events(30)); + d.spend = cached(caches, "cursor-spend", LIVE_TTL, || cursor_spend(30)); + // Deliberately last of the four and deliberately unable to affect them: + // this is an extra allowance, and an account without one is the normal + // case rather than a fault. + let mut why = String::new(); + d.sand = cached(caches, SAND_KEY, SAND_TTL, || match sand_status() { + Ok(v) => Some(v), + Err(e) => { + why = e; + None + } + }); + if d.sand.is_none() && !why.is_empty() { + d.sand_why = why; + } + let path = under_home(".cursor/ai-tracking/ai-code-tracking.db"); + if !std::path::Path::new(&path).exists() { + d.why = "no tracking database".into(); + d.db_missing = true; + return d; + } + // Read-only is not decoration: this is a live agent's working state. + let opened = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .and_then(|con| read_tracking(&con)); + match opened { + Ok(t) => { + d.ok = true; + d.hashes = t.hashes; + d.conversations = t.conversations; + d.models = t.models; + d.by_model = t.by_model; + d.commits = t.commits; + d.lines = t.lines; + d.human_lines = t.human_lines; + d.last = t.last; + } + // Locked or corrupt means the counts are unknown, never zero, and + // the reason is kept for the tab to show. + Err(e) => d.why = e.to_string().chars().take(40).collect(), + } + d +} + +/// The billing cycle as (window seconds, reset epoch), where stated. +fn cycle_of(live: &serde_json::Value) -> (Option<f64>, Option<f64>) { + match (loose(&live["billingCycleStart"]), loose(&live["billingCycleEnd"])) { + (Some(s), Some(e)) if e > s => (Some((e - s) / 1000.0), Some(e / 1000.0)), + _ => (None, None), + } +} + +/// Every quota this agent publishes, for the summary screen. +/// +/// The same three percentages the tab draws, against the same billing +/// cycle. Never marked stale: the CLI keeps no quota cache on disk to fall +/// back to, so a reading is either recent or absent. +pub fn lanes(d: &Data) -> Vec<Lane> { + let Some(live) = d.live.as_ref() else { + return Vec::new(); + }; + let plan = &live["planUsage"]; + let (secs, reset) = cycle_of(live); + let mut out = Vec::new(); + for (label, key, _) in CURSOR_LANES { + let Some(pct) = loose(&plan[*key]) else { + continue; + }; + out.push(Lane { + label: (*label).to_string(), + pct, + window_secs: secs, + reset, + stale: false, + projected: false, + apart: false, + }); + } + // Its own window, not the billing cycle's. The summary ranks lanes + // against each other and prints each one's reset, so handing it the + // monthly dates for a weekly allowance would misreport both. + if let Some((pct, secs, reset)) = d.sand.as_ref().and_then(sand_lane) { + out.push(Lane { + label: "grok bot".to_string(), + pct, + window_secs: secs, + reset, + stale: false, + projected: false, + apart: true, + }); + } + out +} + +/// The three lanes cursor-agent's Usage view shows, plus the cycle. +fn cursor_quota(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let Some(live) = d.live.as_ref() else { + return Vec::new(); + }; + let plan = &live["planUsage"]; + if plan.as_object().is_none_or(|o| o.is_empty()) { + return Vec::new(); + } + let when = match loose(&live["billingCycleEnd"]) { + Some(ends) => { + let left = ends / 1000.0 - now(); + if left > 0.0 { + // Truncated days read "resets in 0d" for the whole of the + // last day of a cycle, which is when it matters most. + format!("resets in {}", left_span(left)) + } else { + "resetting".to_string() + } + } + None => String::new(), + }; + let (cycle_secs, reset_ts) = cycle_of(live); + let elapsed = match (loose(&live["billingCycleStart"]), loose(&live["billingCycleEnd"])) { + (Some(s), Some(e)) if e > s => { + Some((100.0 * (now() * 1000.0 - s) / (e - s)).clamp(0.0, 100.0)) + } + _ => None, + }; + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── QUOTA ── ".into()), + (p.ok.as_str(), "live".into()), + ( + p.dim.as_str(), + crate::claude::scope_phrase(w, 17 + when.chars().count()).into(), + ), + (p.dim.as_str(), when.clone()), + ], + w - 1, + )]; + if let Some(elapsed) = elapsed { + // Just the fact; the +/- column it explains is on every tab now, so + // a per-tab legend was both redundant and the thing that clipped. + rows.push(tc::seg( + &[(p.dim.as_str(), format!(" {:.0}% of the cycle gone", elapsed))], + w - 1, + )); + } + let base = agent_hue("cursor").unwrap(); + for (name, key, stop) in CURSOR_LANES { + let Some(pct) = loose(&plan[*key]) else { + continue; + }; + let used = (pct / 100.0).clamp(0.0, 1.0); + let hue = blend(base, *stop); + // The +/- cell is how far ahead of the clock this lane is: the + // share of the billing period gone minus the share of the + // allowance spent. Positive is a cushion, negative means the lane + // runs out before the cycle does. Pure arithmetic on the cycle + // dates - nothing new is fetched for it. + let (pace_colour, pace_txt) = pace_cell(lead(pct, cycle_secs, reset_ts), p); + let mut line: Vec<(String, String)> = + vec![(p.dim.clone(), format!(" {:<9}", name))]; + line.extend(paced_bar( + used, + elapsed_of(cycle_secs, reset_ts), + w.saturating_sub(40).max(8), + Some(hue), + p, + )); + line.push((pct_colour(pct, Some(hue), p), pct_text(pct))); + line.push((pace_colour, pace_txt)); + let refs: Vec<(&str, String)> = line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + // Grok Bot sits under the three plan lanes, after a gap, because it is + // not one of them: a separate weekly allowance with its own reset, + // which the heading above cannot speak for. So this row carries its + // own countdown, and the blank line says it is not a fourth slice of + // the thing above it. + // + // Full hue rather than a fourth step down the ramp, for two measured + // reasons. The ramp encodes narrowing scope - included, then auto, + // then api - and this allowance is not narrower than any of them, so a + // darker tint would state a relationship that does not exist. And the + // ramp has no room left: the percentage is drawn in the bar's own + // colour, api at 0.62 already measures 5.29 against the background, + // and the next step that reads as distinct from it, 0.50, measures + // 4.10 - under AA. 0.56 clears at 4.66 and is indistinguishable from + // api by eye. Full hue is 10.74, and the gap above does the work the + // colour would have been doing badly. + if let Some((pct, secs, reset)) = d.sand.as_ref().and_then(sand_lane) { + let used = (pct / 100.0).clamp(0.0, 1.0); + let hue = base; + rows.push(String::new()); + let (pace_colour, pace_txt) = pace_cell(lead(pct, secs, reset), p); + let resets = match reset.map(|e| e - now()) { + Some(left) if left > 0.0 => format!(" {}", left_span(left)), + Some(_) => " resetting".to_string(), + None => String::new(), + }; + let mut line: Vec<(String, String)> = + vec![(p.dim.clone(), format!(" {:<9}", "grok bot"))]; + line.extend(paced_bar( + used, + elapsed_of(secs, reset), + // Narrower than the plan lanes by the width of the reset cell, + // so adding that column cannot push this row into a clip. + w.saturating_sub(50).max(8), + Some(hue), + p, + )); + line.push((pct_colour(pct, Some(hue), p), pct_text(pct))); + line.push((pace_colour, pace_txt)); + line.push((p.dim.clone(), resets)); + let refs: Vec<(&str, String)> = line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } else if !d.sand_why.is_empty() { + rows.push(tc::seg( + &[ + (p.dim.as_str(), " grok bot ".into()), + (p.warn.as_str(), d.sand_why.clone()), + ], + w - 1, + )); + } + if let Some(limit) = loose(&plan["limit"]).filter(|v| *v != 0.0) { + // Deliberately dollars rather than a fourth bar. This is spend + // against the plan limit - a different denominator from the three + // lanes above, which are the server's own percentages - and drawing + // it as a bar beside them would invite reading 12% and 2% as the + // same scale. + let spent = loose(&plan["totalSpend"]).unwrap_or(0.0); + let left = loose(&plan["remaining"]); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " spend ".into()), + (p.txt.as_str(), format!("${:.2}", spent / 100.0)), + (p.dim.as_str(), " of ".into()), + (p.txt.as_str(), format!("${:.2}", limit / 100.0)), + ( + p.dim.as_str(), + match left { + Some(v) => format!(" ${:.2} left", v / 100.0), + None => String::new(), + }, + ), + ], + w - 1, + )); + } + rows.push(String::new()); + rows +} + +/// One metered window over the per-day, per-model summary: dollars, tokens +/// and the models under them, costliest first. +fn window_of(by: &serde_json::Value, days: i64, today: NaiveDate) -> (f64, f64, Vec<(String, f64)>) { + let first = (today - Days::days(days - 1)).format("%Y-%m-%d").to_string(); + let (mut cents, mut tokens) = (0.0, 0.0); + let mut models: HashMap<String, f64> = HashMap::new(); + for (day, entries) in by.as_object().into_iter().flatten() { + if day.as_str() < first.as_str() { + continue; + } + for (model, got) in entries.as_object().into_iter().flatten() { + cents += num(got, "cents"); + tokens += num(got, "tokens"); + *models.entry(model.clone()).or_default() += num(got, "cents"); + } + } + let mut ranked: Vec<(String, f64)> = models.into_iter().map(|(m, c)| (m, c / 100.0)).collect(); + ranked.sort_by(|a, b| b.1.total_cmp(&a.1)); + (cents / 100.0, tokens, ranked) +} + +/// What Cursor charges, beside what the same traffic costs at list. +/// +/// Cursor is the one agent that publishes both, so no rate card - and no +/// config - is needed: GetAggregatedUsageEvents returns what it meters +/// against the plan, and the raw events carry their own vendor-rate cents. +/// The gap is what the subscription is worth, and every event states its +/// own discount. +fn cursor_metered(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let nothing = serde_json::Value::Null; + let ev = d.events.as_ref().unwrap_or(¬hing); + let metered = d + .spend + .as_ref() + .and_then(|s| loose(&s["totalCostCents"])) + .unwrap_or(0.0); + let by = &ev["by_day_model"]; + if by.as_object().is_none_or(|o| o.is_empty()) && metered == 0.0 { + return Vec::new(); + } + let today = Local::now().date_naive(); + let span = match ev["days"].as_i64() { + Some(days) if days > 0 => days, + _ => 30, + }; + let mut windows: Vec<Window> = Vec::new(); + for (label, days) in [("today", 1), ("30 days", span)] { + let (cost, tokens, models) = window_of(by, days, today); + windows.push((label.to_string(), cost, tokens, models)); + } + let vendor = loose(&ev["vendor_cents"]).unwrap_or(0.0); + let saves = if vendor > 0.0 && metered > 0.0 { + Some((vendor - metered) / 100.0) + } else { + None + }; + metered_block( + "vendor rates", + &windows, + w, + &[ + ( + "cursor meters".to_string(), + (metered > 0.0).then_some(metered / 100.0), + p.txt.clone(), + ), + ("the plan saves".to_string(), saves, p.ok.clone()), + ], + "", + "account-wide", + "From Cursor's own API, so it covers every device on the account, not just this one.", + p, + ) +} + +/// Spend per day, one column per day, the way the usage page shows it. +/// +/// A bar chart rather than the calendar the token tabs use: this window is +/// thirty days, and thirty cells of a year-wide grid is six columns of +/// colour in a field of dots. Money over a month reads better as a +/// profile, and it is the shape Cursor's own dashboard draws. +fn cursor_daily(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let Some(ev) = d.events.as_ref() else { + return Vec::new(); + }; + let Some(by_day) = ev["by_day"].as_object().filter(|o| !o.is_empty()) else { + return Vec::new(); + }; + let days = match ev["days"].as_i64() { + Some(n) if n > 0 => n, + _ => 30, + }; + let today = Local::now().date_naive(); + let series: Vec<NaiveDate> = (0..days).rev().map(|n| today - Days::days(n)).collect(); + let cents: Vec<f64> = series + .iter() + .map(|day| { + by_day + .get(&day.format("%Y-%m-%d").to_string()) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + }) + .collect(); + let top = cents.iter().cloned().fold(0.0f64, f64::max); + let peak = if top > 0.0 { top } else { 1.0 }; + // The peak's own day, or nothing when every day is zero - a peak of $0 + // has no date worth naming. + let best = (top > 0.0) + .then(|| cents.iter().position(|c| *c == top)) + .flatten() + .map(|i| series[i]); + let label = |d: &NaiveDate| format!("{} {}", MONTHS[d.month0() as usize], d.day()); + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── SPEND / DAY ── ".into()), + (p.dim.as_str(), format!("{}d · peak ", days)), + (p.agent.as_str(), format!("${:.0}", peak / 100.0)), + ( + p.dim.as_str(), + format!( + " on {}", + best.as_ref().map(|b| label(b)).unwrap_or_else(|| "--".into()) + ), + ), + (p.dim.as_str(), " · today ".into()), + ( + p.txt.as_str(), + format!("${:.2}", cents.last().copied().unwrap_or(0.0) / 100.0), + ), + ], + w - 1, + )]; + let avail = w.saturating_sub(3).max(10); + let mut cols: Vec<(f64, String)> = Vec::new(); + for (c, wide) in cents.iter().zip(tc::spread(cents.len(), avail)) { + cols.extend(std::iter::repeat_n((*c, p.agent.clone()), wide)); + } + for line in tc::vbars(&cols, 3, peak) { + let mut parts: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (colour, ch) in &line { + parts.push((colour.as_str(), ch.clone())); + } + rows.push(tc::seg(&parts, w - 1)); + } + rows.push(tc::seg( + &[(tc::RST, " ".into()), (p.grid.as_str(), "─".repeat(cols.len()))], + w - 1, + )); + let (left, right) = (label(&series[0]), label(&series[series.len() - 1])); + rows.push(tc::seg( + &[ + (p.dim.as_str(), format!(" {}", left)), + ( + p.dim.as_str(), + " ".repeat(cols.len().saturating_sub(left.len() + right.len()).max(1)), + ), + (p.dim.as_str(), right), + ], + w - 1, + )); + rows.push(String::new()); + rows +} + +/// Where the money went, per model, over the last 30 days. +fn cursor_spend_rows(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let Some(spend) = d.spend.as_ref() else { + return Vec::new(); + }; + let Some(aggs) = spend["aggregations"].as_array().filter(|a| !a.is_empty()) else { + return Vec::new(); + }; + let grab = |key: &str| loose(&spend[key]).unwrap_or(0.0); + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── SPEND ── ".into()), + (p.dim.as_str(), "last 30d · ".into()), + (p.agent.as_str(), format!("${:.2}", grab("totalCostCents") / 100.0)), + (p.dim.as_str(), " in ".into()), + (p.txt.as_str(), big_num(grab("totalInputTokens"))), + (p.dim.as_str(), " · out ".into()), + (p.txt.as_str(), big_num(grab("totalOutputTokens"))), + (p.dim.as_str(), " · cache ".into()), + (p.txt.as_str(), big_num(grab("totalCacheReadTokens"))), + ], + w - 1, + )]; + let mut models: Vec<&serde_json::Value> = aggs.iter().collect(); + let cents_of = |a: &serde_json::Value| loose(&a["totalCents"]).unwrap_or(0.0); + models.sort_by(|a, b| cents_of(b).total_cmp(¢s_of(a))); + let top = match cents_of(models[0]) { + v if v != 0.0 => v, + _ => 1.0, + }; + for a in models.iter().take(6) { + let cents = cents_of(a); + let bar = tc::meter(cents / top, w.saturating_sub(44).max(6)); + let filled = bar.chars().filter(|c| *c == '█').count(); + let name = match text(a, "modelIntent") { + s if s.is_empty() => "?".to_string(), + s => s, + }; + rows.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {}", tc::pad(&name, 26))), + (p.agent.as_str(), format!("{:>9} ", format!("${:.2}", cents / 100.0))), + (p.agent.as_str(), bar.chars().take(filled).collect::<String>()), + (p.grid.as_str(), bar.chars().skip(filled).collect::<String>()), + ], + w - 1, + )); + } + rows.push(String::new()); + rows +} + +fn cursor_plan_rows(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let Some(info) = d.plan.as_ref() else { + return Vec::new(); + }; + let plan = &info["planInfo"]; + if plan.as_object().is_none_or(|o| o.is_empty()) { + return Vec::new(); + } + let mut pairs: Vec<(String, String)> = Vec::new(); + let price = text(plan, "price"); + if !price.is_empty() { + pairs.push(("price".into(), price)); + } + if let Some(inc) = loose(&plan["includedAmountCents"]).filter(|v| *v != 0.0) { + pairs.push(("included".into(), format!("${:.2} per cycle", inc / 100.0))); + } + let owner = text(plan, "planOwner").replace("PLAN_OWNER_", "").to_lowercase(); + if !owner.is_empty() { + pairs.push(("billing".into(), owner)); + } + plan_rows(&text(plan, "planName"), &pairs, w, "", None, "", p) +} + +/// The tab's own body: the lanes, the spend charts, and the authorship +/// counts from the tracking database. +fn cursor_tab(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let mut rows = cursor_quota(d, w, p); + rows.extend(cursor_daily(d, w, p)); + rows.extend(cursor_spend_rows(d, w, p)); + if !d.ok { + // An unreadable database makes the counts unknown, not zero, and + // which kind of unreadable matters: absent means run the agent, + // locked or corrupt names sqlite's own reason and will likely fix + // itself. usage.py shows a fixed "no tracking database" either way, + // which is false for a locked file. + let why = if d.why.is_empty() { "not read yet" } else { d.why.as_str() }; + let note = no_local( + &format!("No AI-written-code counts: {}.", why), + if d.db_missing { run_hint("cursor") } else { "" }, + w, + p, + ); + if rows.is_empty() { + return note; + } + return add_section(rows, note); + } + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── AI-WRITTEN CODE ── ".into()), + ( + p.dim.as_str(), + format!("last seen {} ago", ago(d.last.map(|ms| ms / 1000.0).unwrap_or(0.0))), + ), + ], + w - 1, + )); + let (ai, human) = (d.lines, d.human_lines); + let total = ai + human; + let cells: Vec<(&str, String, &str)> = vec![ + ("tracked edits", commas(d.hashes), p.agent.as_str()), + ("conversations", commas(d.conversations), p.txt.as_str()), + ("scored commits", commas(d.commits), p.txt.as_str()), + ("lines by agent", commas(ai), p.agent.as_str()), + ("lines by hand", commas(human), p.txt.as_str()), + ("models used", format!("{}", d.models), p.dim.as_str()), + ]; + let label_w = cells.iter().map(|c| c.0.len()).max().unwrap_or(8); + let ncols = if w.saturating_sub(2) / 2 >= label_w + 11 { 2 } else { 1 }; + let cw = w.saturating_sub(2) / ncols; + let val_w = cw.saturating_sub(label_w + 3).max(5); + for chunk in cells.chunks(ncols) { + let mut line: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + for (label, value, colour) in chunk { + line.push((p.dim.as_str(), format!(" {} ", tc::pad(label, label_w)))); + line.push((colour, tc::pad(value, val_w))); + } + rows.push(tc::seg(&line, w - 1)); + } + if total > 0 { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── WHO WROTE IT ── ".into()), + (p.dim.as_str(), format!("{} lines scored", commas(total))), + ], + w - 1, + )); + let split = [ + (ai as f64 / total as f64, p.agent.clone()), + (human as f64 / total as f64, p.dim.clone()), + ]; + let mut bar: Vec<(&str, String)> = vec![(tc::RST, " ".into())]; + let segs = tc::stacked_bar(&split, w.saturating_sub(3).max(10)); + for (colour, run) in &segs { + bar.push((colour.as_str(), run.clone())); + } + rows.push(tc::seg(&bar, w - 1)); + rows.push(tc::seg( + &[ + ( + p.agent.as_str(), + format!(" ▇ agent {} ({:.0}%)", commas(ai), 100.0 * ai as f64 / total as f64), + ), + ( + p.dim.as_str(), + format!( + " ▇ hand {} ({:.0}%)", + commas(human), + 100.0 * human as f64 / total as f64 + ), + ), + ], + w - 1, + )); + } + if !d.by_model.is_empty() { + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── BY MODEL ── ".into()), + (p.dim.as_str(), "tracked edits".into()), + ], + w - 1, + )); + let top = d.by_model[0].1.max(1); + for (name, n) in d.by_model.iter().take(5) { + let bar = tc::meter(*n as f64 / top as f64, w.saturating_sub(36).max(6)); + let filled = bar.chars().filter(|c| *c == '█').count(); + rows.push(tc::seg( + &[ + (p.txt.as_str(), format!(" {}", tc::pad(name, 22))), + (p.agent.as_str(), format!("{:>7} ", commas(*n))), + (p.agent.as_str(), bar.chars().take(filled).collect::<String>()), + (p.grid.as_str(), bar.chars().skip(filled).collect::<String>()), + ], + w - 1, + )); + } + } + rows.push(String::new()); + rows.push(tc::seg( + &[( + p.dim.as_str(), + " Authorship, not spend: this is how much code the agent wrote,".into(), + )], + w - 1, + )); + rows.push(tc::seg( + &[(p.dim.as_str(), " which is a different question from what it cost.".into())], + w - 1, + )); + rows +} + +/// The whole tab: the lanes and charts, what Cursor metered, and the plan +/// the percentages are percentages of. +pub fn tab(d: &Data, w: usize, _h: usize, _cfg: &Config, p: &Palette) -> Vec<String> { + // METERED sits last-but-one on every tab, so Cursor's - which is + // published by the server rather than priced from a rate card - lands + // in the same place as everyone else's rather than floating up beside + // its quota. + let body = add_section(cursor_tab(d, w, p), cursor_metered(d, w, p)); + add_section(body, cursor_plan_rows(d, w, p)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_schema() -> Connection { + let con = Connection::open_in_memory().unwrap(); + con.execute_batch( + "create table ai_code_hashes \ + (hash text, conversationId text, model text, timestamp integer);\ + create table scored_commits \ + (hash text, linesAdded integer, humanLinesAdded integer);", + ) + .unwrap(); + con + } + + #[test] + fn the_tracking_reader_counts_distinct_not_rows() { + let con = empty_schema(); + // Two rows share a conversation so the distinct counts actually + // discriminate from the plain ones. + con.execute_batch( + "insert into ai_code_hashes values + ('a1', 'conv-1', 'model-a', 1000), + ('a2', 'conv-1', 'model-a', 2000), + ('a3', 'conv-2', 'model-b', 3000);\ + insert into scored_commits values ('c1', 120, 30), ('c2', 10, 5);", + ) + .unwrap(); + let t = read_tracking(&con).unwrap(); + assert_eq!(t.hashes, 3); + assert_eq!(t.conversations, 2); + assert_eq!(t.models, 2); + assert_eq!(t.by_model[0], ("model-a".to_string(), 2)); + assert_eq!(t.commits, 2); + assert_eq!(t.lines, 130); + assert_eq!(t.human_lines, 35); + assert_eq!(t.last, Some(3000.0)); + } + + #[test] + fn an_empty_database_reads_as_zeros_not_an_error() { + // sum() over no rows is NULL, and NULL here is a true zero - a + // machine that has scored nothing - not a failure to read. + let t = read_tracking(&empty_schema()).unwrap(); + assert_eq!(t.commits, 0); + assert_eq!(t.lines, 0); + assert_eq!(t.human_lines, 0); + assert_eq!(t.last, None); + } + + #[test] + fn a_broken_database_is_a_why_not_a_zero() { + // No tables at all: the reader must error so the tab can say why, + // never coast through with zeros that look like idleness. + let con = Connection::open_in_memory().unwrap(); + let why = match read_tracking(&con) { + Ok(_) => String::new(), + Err(e) => e.to_string(), + }; + assert!(!why.is_empty(), "a missing table must carry a reason"); + } + + /// A real-shaped response, from the field names CodexBar's Cursor + /// provider decodes. Weekly window: 1700000000 -> 1700604800. + fn sand(flag: serde_json::Value, pct: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "hasNonZeroIncludedLimit": flag, + "usagePercent": pct, + "currentPeriodStart": "2023-11-14T22:13:20Z", + "nextResetTimestampUtc": "2023-11-21T22:13:20Z", + "hasAvailableUsage": true, + }) + } + + #[test] + fn an_account_with_no_bot_allowance_draws_no_bot_bar() { + // The whole point of the flag. A 0% bar here would state a limit + // the account was never given, which is the one thing this widget + // must not do - and 0% is exactly what an untouched allowance + // looks like, so the two are indistinguishable without it. + assert_eq!(sand_lane(&sand(serde_json::json!(false), serde_json::json!(0.0))), None); + assert_eq!(sand_lane(&sand(serde_json::json!(null), serde_json::json!(12.0))), None); + assert_eq!(sand_lane(&serde_json::json!({})), None); + // The flag alone is not enough: no percentage, no bar. + assert_eq!(sand_lane(&sand(serde_json::json!(true), serde_json::json!(null))), None); + } + + #[test] + fn the_bot_lane_keeps_its_own_week_not_the_billing_cycle() { + let got = sand_lane(&sand(serde_json::json!(true), serde_json::json!(62.5))); + let (pct, secs, reset) = got.expect("an allowance the account has"); + assert!((pct - 62.5).abs() < 1e-9); + // Seven days, not the month the plan lanes run to. + assert_eq!(secs, Some(604_800.0)); + assert_eq!(reset, Some(1_700_604_800.0)); + + // And it reaches the summary screen as its own lane, alongside the + // plan's - which is what puts it on [+]. + let d = Data { + live: Some(serde_json::json!({ + "planUsage": { "totalPercentUsed": 9.5 }, + "billingCycleStart": "1700000000000", + "billingCycleEnd": "1702592000000", + })), + sand: Some(sand(serde_json::json!(true), serde_json::json!(62.5))), + ..Data::default() + }; + let lanes = lanes(&d); + assert_eq!(lanes.len(), 2); + let bot = lanes.iter().find(|l| l.label == "grok bot").expect("a grok bot lane"); + assert_eq!(bot.window_secs, Some(604_800.0), "took the monthly window"); + assert_eq!(bot.reset, Some(1_700_604_800.0), "took the monthly reset"); + // The plan lane beside it must be untouched by any of this. + let inc = lanes.iter().find(|l| l.label == "included").unwrap(); + assert_eq!(inc.window_secs, Some(2_592_000.0)); + } + + #[test] + fn a_bot_reading_that_failed_leaves_the_plan_bars_alone() { + // Best-effort by contract: this lane is an extra, and an extra that + // can take the tab down with it is worse than no extra. + let d = Data { + live: Some(serde_json::json!({ + "planUsage": { "totalPercentUsed": 9.5, "limit": 40000.0, "totalSpend": 33099.0 }, + "billingCycleStart": "1700000000000", + "billingCycleEnd": "1702592000000", + })), + sand: None, + sand_why: "cursor.com did not answer".into(), + ..Data::default() + }; + assert_eq!(lanes(&d).len(), 1, "a failed extra invented or removed a lane"); + let rows = cursor_quota(&d, 110, &palette()); + let joined = rows.join("\n"); + assert!(joined.contains("included"), "plan bar lost: {}", joined); + assert!(joined.contains("$330.99"), "spend lost: {}", joined); + assert!(joined.contains("did not answer"), "reason not shown: {}", joined); + } + + #[test] + fn a_lane_that_publishes_nothing_is_absent_not_zero() { + // autoPercentUsed is missing, so no "auto" lane may appear - a + // fabricated 0% is indistinguishable from an untouched lane. + let d = Data { + live: Some(serde_json::json!({ + "planUsage": { "totalPercentUsed": 41.5, "apiPercentUsed": "2.5" }, + "billingCycleStart": "1700000000000", + "billingCycleEnd": "1702592000000", + })), + ..Data::default() + }; + let got = lanes(&d); + assert_eq!(got.len(), 2); + assert_eq!(got[0].label, "included"); + assert!((got[0].pct - 41.5).abs() < 1e-9); + // Connect writes int64 as strings; the cycle still has to be read. + assert_eq!(got[0].window_secs, Some(2_592_000.0)); + assert_eq!(got[0].reset, Some(1_702_592_000.0)); + // A percentage that arrives as a string is still a percentage. + assert_eq!(got[1].label, "api"); + assert!((got[1].pct - 2.5).abs() < 1e-9); + assert!(lanes(&Data::default()).is_empty()); + } + + #[test] + fn a_window_takes_only_its_own_days() { + let today = NaiveDate::parse_from_str("2026-08-23", "%Y-%m-%d").unwrap(); + let by = serde_json::json!({ + "2026-08-23": { "model-x": { "cents": 250.0, "tokens": 1000.0 } }, + "2026-08-01": { "model-x": { "cents": 100.0, "tokens": 400.0 }, + "model-y": { "cents": 500.0, "tokens": 900.0 } }, + "2026-07-01": { "model-x": { "cents": 9999.0, "tokens": 9.0 } }, + }); + let (cost, tokens, _) = window_of(&by, 1, today); + assert!((cost - 2.50).abs() < 1e-9); + assert!((tokens - 1000.0).abs() < 1e-9); + // Thirty days reaches 1 August but not 1 July. + let (cost, _, models) = window_of(&by, 30, today); + assert!((cost - 8.50).abs() < 1e-9); + // Costliest model first, in dollars. + assert_eq!(models[0].0, "model-y"); + assert!((models[0].1 - 5.0).abs() < 1e-9); + } + + #[test] + fn paging_stops_at_the_window_not_the_history() { + let cut = now() - 30.0 * 86400.0; + let recent = ((now() - 3600.0) * 1000.0) as i64; + let ancient = ((cut - 86400.0) * 1000.0) as i64; + let events = vec![ + serde_json::json!({ + "timestamp": recent.to_string(), "model": "model-x", + "tokenUsage": { "totalCents": "12.5", + "inputTokens": "100", "outputTokens": "50" }, + }), + serde_json::json!({ + "timestamp": ancient.to_string(), "model": "model-x", + "tokenUsage": { "totalCents": 999.0, + "inputTokens": 9, "outputTokens": 9 }, + }), + ]; + let mut t = Tally::default(); + let oldest = tally_events(&events, cut, &mut t); + // The old event is not counted, but its age is what tells the + // caller this page reached past the window and paging can stop. + assert_eq!(t.counted, 1); + assert!((t.vendor_cents - 12.5).abs() < 1e-9); + assert!((t.tokens - 150.0).abs() < 1e-9); + assert!(oldest < cut); + } + + #[test] + fn the_quota_names_all_three_lanes_and_the_spend_in_dollars() { + let p = palette(); + let start = ((now() - 10.0 * 86400.0) * 1000.0) as i64; + let end = ((now() + 20.0 * 86400.0) * 1000.0) as i64; + let d = Data { + live: Some(serde_json::json!({ + "planUsage": { + "totalPercentUsed": 41.0, "autoPercentUsed": 12.0, + "apiPercentUsed": 3.0, + "limit": "40000", "totalSpend": "16400", "remaining": "23600", + }, + "billingCycleStart": start.to_string(), + "billingCycleEnd": end.to_string(), + })), + ..Data::default() + }; + let joined = cursor_quota(&d, 100, &p).join("\n"); + for want in [ + "included", "auto", "api", "$164.00", "$400.00", "$236.00", + "of the cycle gone", "resets in", + ] { + assert!(joined.contains(want), "missing {}", want); + } + } + + #[test] + fn an_unreadable_database_says_why_instead_of_zeros() { + let p = palette(); + let d = Data { + why: "database is locked".into(), + ..Data::default() + }; + let joined = cursor_tab(&d, 90, &p).join("\n"); + assert!(joined.contains("database is locked")); + assert!(!joined.contains("AI-WRITTEN CODE")); + } + + #[test] + fn the_metered_section_reports_the_gap_the_plan_covers() { + let p = palette(); + let today = Local::now().date_naive().format("%Y-%m-%d").to_string(); + let d = Data { + events: Some(serde_json::json!({ + "by_day": { today.clone(): 1000.0 }, + "by_day_model": { + today: { "model-x": { "cents": 1000.0, "tokens": 5000.0 } }, + }, + "vendor_cents": 1000.0, + "days": 30, + })), + // Connect writes the metered figure as a string too. + spend: Some(serde_json::json!({ "totalCostCents": "400" })), + ..Data::default() + }; + let joined = cursor_metered(&d, 100, &p).join("\n"); + // Vendor list $10.00, Cursor metered $4.00, so the plan saved $6.00 + // - and the scope is named, because the QUOTA above it is too. + for want in ["account-wide", "$10.00", "cursor meters", "$4.00", "the plan saves", "$6.00"] + { + assert!(joined.contains(want), "missing {}", want); + } + } + + #[test] + fn thousands_read_as_thousands() { + assert_eq!(commas(0), "0"); + assert_eq!(commas(999), "999"); + assert_eq!(commas(1_000), "1,000"); + assert_eq!(commas(1_482_113), "1,482,113"); + } +} diff --git a/widgets/src/bin/usage/grok.rs b/widgets/src/bin/usage/grok.rs new file mode 100644 index 0000000..d5a1da7 --- /dev/null +++ b/widgets/src/bin/usage/grok.rs @@ -0,0 +1,1579 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Grok: what its transcripts spent, and the quota that arrives elsewhere. +//! +//! Grok writes a running totalTokens on every session event. Deltas between +//! consecutive events, bucketed by the event's own timestamp, give the +//! per-day figures - the running total alone would credit an entire session +//! to whichever day it happened to be read on. + +use std::collections::{HashMap, HashSet}; + +use chrono::{Datelike, Local, NaiveDate, TimeZone}; +use toys_core as tc; + +use crate::shared::*; +use crate::*; + +/// Where the CLI keeps its transcripts. Nested a couple of levels down, so +/// it is walked rather than listed. +const SESSIONS: &str = ".grok/sessions"; +/// The quota is not in the session transcripts: it arrives on the client +/// log, which the CLI writes as it talks to the server. +const LOG: &str = ".grok/logs/unified.jsonl"; +/// Where the CLI leaves the token it authenticates with. +const AUTH: &str = ".grok/auth.json"; +/// Seconds to wait on the billing call before falling back to the log. +const QUOTA_TIMEOUT: u64 = 6; +/// The CLI, whose only job here is to refresh the token it owns. +const CLI: &str = ".grok/bin/grok"; +/// Cache key for the billing reading. +const PING_KEY: &str = "grok:billing"; +/// Cache key for the last session-end refresh, so one ending refreshes once. +const SEEN_KEY: &str = "grok:session-seen"; +/// Cache key for the last expiry a refresh was attempted against. +const EXPIRY_KEY: &str = "grok:token-expiry"; +/// Refresh this long before the token actually lapses, so the ask that +/// follows is not the one that discovers it has. +const TOKEN_MARGIN: f64 = 600.0; +/// How long a session must be quiet before it counts as over. Long enough +/// that a pause for thought is not an ending. +const SESSION_QUIET: f64 = 120.0; +/// And how long before it is old news. Launching the widget days after the +/// last session should not start the CLI to refresh a window nobody is +/// watching. +const SESSION_STALE: f64 = 6.0 * 3600.0; +/// The CLI's own billing endpoint. Same reading its `/usage` shows, and the +/// only account-wide source Grok has that is not a file on this disk. +const BILLING: &str = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +/// The credit reading is one line among the client log's chatter and is +/// only rewritten when the server sends a new one, so the tail has to be +/// long enough to still contain one after a busy session. +const LOG_TAIL: u64 = 2 * 1024 * 1024; +/// Namespace for this reader's per-file cache entries, so the prune below +/// can find its own and leave every other reader's alone. +const CACHE: &str = "grok:session:"; + +/// Grok's credit window, as its own CLI receives it. +/// +/// Not hidden and not inferred: the server sends it and the CLI writes it +/// into the log under `.ctx.config`. An earlier pass in the Python +/// concluded no quota existed, having grepped for limit/quota/remaining/ +/// reset - the keys are `creditUsagePercent` and `currentPeriod`, so the +/// search missed them and the tab said so in print for a day. +#[derive(Clone, Default)] +struct Quota { + pct: Option<f64>, + /// The same week split by product, where the answer carries it: + /// GrokBuild, GrokChat, GrokImagine. Empty when it does not, which is + /// every reading the log recorded - this arrived with the credits + /// endpoint and the log lines predate it. + products: Vec<(String, f64)>, + /// When this reading was taken, as epoch seconds - the log line's own + /// `ts`, or the moment of the fetch. Not when it was read: the tab + /// judges a reading by its age, and those are different numbers. + taken: Option<f64>, + kind: String, + start: String, + end: String, + tier: String, + on_demand_used: Option<f64>, + on_demand_cap: Option<f64>, + prepaid: Option<f64>, +} + +/// What the transcripts on this machine recorded, plus the account-wide +/// credit window they say nothing about. +#[derive(Clone, Default)] +pub struct Data { + ok: bool, + /// Files that carried a non-zero total, and files looked at. + sessions: usize, + files: usize, + total: f64, + daily: HashMap<NaiveDate, f64>, + /// Newest transcript mtime, as epoch seconds. + last: f64, + quota: Option<Quota>, + /// True when the quota came from the server just now rather than from + /// the log. A number nobody labelled as old reads as current, and this + /// one was out by more than double the last time it went unlabelled. + quota_live: bool, + /// When the server was last asked, as epoch seconds. Zero when it never + /// has been - the asking is off unless it is turned on. + quota_at: f64, + /// Seconds between asks, so the tab can say what the interval is rather + /// than leaving the reader to find it in a config file. + quota_every: f64, + /// Why the figure on screen is not the server's, when it is not. Empty + /// when it is, or when nothing is asking. + quota_why: String, +} + +/// The integer following `key` on a line. +/// +/// The transcripts are one JSON object per event and only two numbers on +/// each are wanted, so the line is scanned rather than parsed. Grok's +/// events carry whole tool results, and building a document out of every +/// one of them to reach two integers costs more than the entire read. +fn int_after(line: &str, key: &str) -> Option<f64> { + let at = line.find(key)? + key.len(); + let digits: String = line[at..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + digits.parse().ok() +} + +/// One transcript's spend, as a total and by day. +/// +/// The running counter is followed rather than trusted: a session that +/// resumes replays earlier events, so the count can go backwards, and a +/// decrease is a replay rather than negative usage. Days are UTC because +/// the timestamp is epoch milliseconds and the Python bucketed it that way. +fn session_days(body: &str) -> (f64, HashMap<String, f64>) { + let (mut total, mut prev) = (0.0f64, 0.0f64); + let mut days: HashMap<String, f64> = HashMap::new(); + for line in body.lines() { + let Some(value) = int_after(line, "\"totalTokens\":") else { + continue; + }; + let step = value - prev; + prev = prev.max(value); + if step <= 0.0 { + continue; + } + // A step whose event carries no timestamp is dropped rather than + // banked against whichever day was read last: a calendar that + // invents a day is worse than one that is short by a step. + let Some(ms) = int_after(line, "\"agentTimestampMs\":") else { + continue; + }; + // Local, not UTC, because Claude, Codex and Cursor all bucket + // locally and day_calendar draws the four on one wall under the + // same headings. The stamp is epoch milliseconds with no zone of + // its own to honour, so there is nothing here arguing for UTC the + // way Copilot's reset boundary does. + let Some(at) = Local.timestamp_millis_opt(ms as i64).single() else { + continue; + }; + *days.entry(at.date_naive().to_string()).or_insert(0.0) += step; + total += step; + } + (total, days) +} + +/// A credit figure from the log, which arrives as `{"val": n}`. +/// +/// Read as a number or as a string, because the server writes int64s as +/// strings - JSON has no room for them - and a percentage that arrives +/// quoted would otherwise drop the whole quota block. +fn val_of(value: &serde_json::Value) -> Option<f64> { + value.as_f64().or_else(|| value.as_str()?.parse().ok()) +} + +/// The most recent credit reading on the client log. +/// +/// The newest *period* wins rather than the newest line: the log repeats +/// the same window on every exchange, and a line further down describing +/// an older window is a reply that was still in flight. +fn newest_quota<'a>(lines: impl Iterator<Item = &'a str>) -> Option<Quota> { + let mut best: Option<Quota> = None; + for line in lines { + // Rejected on a substring first: almost every line of this log is + // something else, and parsing two megabytes of them to find the + // few that carry a credit reading costs more than the read. + if !line.contains("creditUsagePercent") { + continue; + } + let Ok(d) = serde_json::from_str::<serde_json::Value>(line) else { + continue; + }; + let cfg = &d["ctx"]["config"]; + // The key has to be there, but it does not have to hold a number: + // a reading the server sent with a null percentage still names the + // tier and the billing period, and those rows are real. + if !cfg + .as_object() + .is_some_and(|o| o.contains_key("creditUsagePercent")) + { + continue; + } + let period = &cfg["currentPeriod"]; + let got = Quota { + pct: val_of(&cfg["creditUsagePercent"]), + products: products_of(cfg), + taken: iso_epoch(&text(&d, "ts")), + kind: text(period, "type"), + start: text(period, "start"), + end: text(period, "end"), + tier: text(&d["ctx"], "subscriptionTier"), + on_demand_used: val_of(&cfg["onDemandUsed"]["val"]), + on_demand_cap: val_of(&cfg["onDemandCap"]["val"]), + prepaid: val_of(&cfg["prepaidBalance"]["val"]), + }; + let better = match &best { + None => true, + Some(had) => got.start >= had.start, + }; + if better { + best = Some(got); + } + } + best +} + +/// What every transcript here spent, and what the server last said about +/// the account's credits. +/// The server if it is allowed and will answer, the log if not. +/// +/// Held between asks rather than asked on every frame: the pane redraws +/// every thirty seconds and this window moves over days, so the interval is +/// the configured one and the reading in between is the one already had. +/// The credit window, whether it came from the server, when it was asked +/// for, and - when it did not - which of the four reasons applies. +fn quota_now(caches: &mut Caches, cfg: &Config) -> (Option<Quota>, bool, f64, String) { + let from_log = || { + newest_quota(tail_lines(&under_home(LOG), LOG_TAIL).iter().map(String::as_str)) + }; + if !cfg.grok_ping { + return (from_log(), false, 0.0, String::new()); + } + let key = match usable_token() { + Ok(k) => k, + Err(why) => return (from_log(), false, 0.0, why), + }; + let ttl = (cfg.grok_ping_minutes * 60.0).max(60.0); + let got = cached(caches, PING_KEY, ttl, || fetch_billing(&key, QUOTA_TIMEOUT)); + // When the ask was actually made, which is not this frame most of the + // time. The tab reports it, so it has to be the fetch and not the read. + let at = caches.live.get(PING_KEY).map(|(when, _, _)| *when).unwrap_or(0.0); + match got.as_ref() { + None => (from_log(), false, at, "x.ai did not answer".to_string()), + Some(body) => match quota_from(body) { + Some(mut q) => { + let why = String::new(); + if q.pct.is_none() { + // The server named the window but not the spend. The log + // may still hold a percentage, and it is usable only if + // it is about this same window: a figure from a window + // that has closed is not this one's, however it got here. + match from_log() { + Some(l) if l.start == q.start && l.end == q.end => { + q.pct = l.pct; + q.taken = l.taken; + } + // No reason recorded here on purpose. The row that + // stands where the bar would be already says there + // is no figure for this period, and the freshness + // line saying it too was the same sentence twice. + // quota_why is for the cases that line cannot show: + // a lapsed token, a refusal, an unreadable answer. + _ => {} + } + } + (Some(q), true, at, why) + } + None => (from_log(), false, at, "x.ai sent no usable reading".to_string()), + }, + } +} + +/// Run the Grok CLI once, for its side effect: it refreshes the token in +/// `auth.json` on startup, and that token is what the billing request needs. +/// +/// Without this the asking works until the token lapses and then quietly +/// stops working, which is the failure it was meant to fix. With it the +/// refresh happens when a session has just ended - the moment the numbers +/// have changed and nobody is at the keyboard waiting. +/// +/// It starts somebody else's program, which is part of what grok_ping asks +/// for rather than a setting of its own - polling that stops working the +/// moment the token lapses is not what anybody turned on. The handshake is +/// the smallest one the agent answers: initialize, then close. +fn refresh_token() { + use std::io::Write; + let Ok(mut child) = std::process::Command::new(under_home(CLI)) + .args(["agent", "stdio"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + else { + return; + }; + if let Some(mut pipe) = child.stdin.take() { + let _ = pipe.write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\ + \"params\":{\"protocolVersion\":1,\"clientCapabilities\":{}}}\n", + ); + } + let _ = child.wait(); +} + +pub fn read(caches: &mut Caches, cfg: &Config) -> Data { + use std::os::unix::fs::MetadataExt; + let mut files = Vec::new(); + walk(&under_home(SESSIONS), "updates.jsonl", &mut files); + if files.is_empty() { + // The log is still read. The credit window is account-wide and true + // whatever this disk holds, and hiding it because the local half is + // missing is the failure this repo keeps paying for. ok stays false, + // so the tab says there are no sessions - under the quota, not + // instead of it. + let (quota, quota_live, quota_at, quota_why) = quota_now(caches, cfg); + return Data { + quota, + quota_live, + quota_at, + quota_why, + quota_every: cfg.grok_ping.then(|| cfg.grok_ping_minutes * 60.0).unwrap_or(0.0), + ..Data::default() + }; + } + let (mut total, mut sessions, mut newest) = (0.0f64, 0usize, 0.0f64); + let mut daily: HashMap<NaiveDate, f64> = HashMap::new(); + let mut seen: HashSet<String> = HashSet::new(); + for path in &files { + let Ok(meta) = std::fs::metadata(path) else { + continue; + }; + newest = newest.max(meta.mtime() as f64); + // Keyed on the file's own (mtime, size): a transcript that has not + // been appended to cannot have different deltas, and re-reading + // every session on every refresh to learn that is the whole cost + // of this tab. + let key = format!("{}{}:{}:{}", CACHE, path, meta.mtime(), meta.size()); + let got = cached(caches, &key, PLAN_TTL, || { + let (total, days) = session_days(&std::fs::read_to_string(path).ok()?); + Some(serde_json::json!({"total": total, "daily": days})) + }); + seen.insert(key); + let Some(got) = got else { + continue; + }; + let spent = got["total"].as_f64().unwrap_or(0.0); + if spent <= 0.0 { + continue; + } + sessions += 1; + total += spent; + for (day, n) in got["daily"].as_object().into_iter().flatten() { + let Ok(at) = NaiveDate::parse_from_str(day, "%Y-%m-%d") else { + continue; + }; + *daily.entry(at).or_insert(0.0) += n.as_f64().unwrap_or(0.0); + } + } + // What makes a reading safe to trust - that its key names the size and + // mtime it was read at - is also what makes it dead the moment the + // session appends a line. Without this the live session's old readings + // pile up for as long as the pane runs. + caches + .live + .retain(|key, _| !key.starts_with(CACHE) || seen.contains(key)); + // A session that has just ended is the moment the numbers have changed + // and nobody is waiting on the pane. Refreshing the token then keeps the + // asking working; refreshing while a session is still running would mean + // starting the CLI under somebody who is using it. + if cfg.grok_ping && newest > 0.0 { + let quiet = now() - newest; + let handled = caches + .live + .get(SEEN_KEY) + .and_then(|(_, v, _)| v.as_ref()) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + if (SESSION_QUIET..SESSION_STALE).contains(&quiet) && newest > handled { + refresh_token(); + caches.live.insert( + SEEN_KEY.to_string(), + (now(), Some(serde_json::json!(newest)), f64::MAX), + ); + // The token is new, so the reading held from before it is not + // the best one available any more. + caches.live.remove(PING_KEY); + } + } + // The gate above fires only in the six hours after a session ends, and + // the token lapses on its own clock about that often - measured at six + // hours on the machine this was found on. So anyone who has not run + // Grok since yesterday had the asking silently switched off, which is + // the exact failure the refresh above exists to prevent and says so in + // its own comment. Reaching it needs a gate keyed on the token rather + // than on a session. + // + // Both of the original guards are kept: nothing happens unless asking + // was turned on, and nothing starts the CLI underneath somebody who is + // using it. What is dropped is the upper bound on how long ago they + // last did. + // + // Deduped on the expiry value, not on time. A refresh that does not + // move the expiry - a login that has genuinely run out - must be tried + // once and then left alone; a CLI respawned every five minutes for ever + // is a worse failure than the stale row it was trying to fix. + if cfg.grok_ping { + let quiet = if newest > 0.0 { now() - newest } else { f64::MAX }; + let expiry = token_expiry(); + if let Some(expiry) = expiry { + let tried = caches + .live + .get(EXPIRY_KEY) + .and_then(|(_, v, _)| v.as_ref()) + .and_then(|v| v.as_f64()) + .unwrap_or(f64::NAN); + if quiet > SESSION_QUIET && expiry <= now() + TOKEN_MARGIN && tried != expiry { + refresh_token(); + caches.live.insert( + EXPIRY_KEY.to_string(), + (now(), Some(serde_json::json!(expiry)), f64::MAX), + ); + caches.live.remove(PING_KEY); + } + } + } + let quota_read = quota_now(caches, cfg); + Data { + ok: true, + sessions, + files: files.len(), + total, + daily, + last: newest, + quota: quota_read.0, + quota_live: quota_read.1, + quota_at: quota_read.2, + quota_why: quota_read.3, + quota_every: cfg.grok_ping.then(|| cfg.grok_ping_minutes * 60.0).unwrap_or(0.0), + } +} + +/// Every quota this agent publishes, for the summary screen. +/// +/// One lane: the credit window is the only allowance Grok states. The +/// window length is offered only when both ends parse and run forwards, +/// but the reset is offered whenever the end does - a countdown is +/// readable without knowing how long the window was. +/// The credit window as the server has it right now. +/// +/// Grok was the only agent here reading its quota off the disk, and the +/// number that produced was whatever its CLI last wrote - 23% from a window +/// that had closed, where the account had since spent 57% of the one we are +/// actually in. More than double, and nothing on screen said so. +/// +/// The CLI leaves a bearer token in `auth.json` and this is the endpoint it +/// calls with it. An expired token is not sent: it would come back 401 after +/// a round trip, and the log is a better answer than a failed request. The +/// CLI refreshes the token whenever it runs, so this works for as long as +/// Grok is in use and stops when it is not, which is the honest shape. +/// The token entry the CLI left on disk, or why there is not one. +/// +/// Keyed by issuer and account, so the entry is found by shape rather than +/// by a name that is different on every machine. +fn token_entry() -> Result<serde_json::Value, String> { + let raw = std::fs::read_to_string(under_home(AUTH)) + .map_err(|_| "the Grok CLI has left no token on this disk".to_string())?; + let parsed: serde_json::Value = serde_json::from_str(&raw) + .map_err(|_| "the token the CLI left is not readable JSON".to_string())?; + parsed + .as_object() + .and_then(|o| { + o.values() + .find(|v| v.get("key").and_then(|k| k.as_str()).is_some_and(|k| !k.is_empty())) + }) + .cloned() + .ok_or_else(|| "the token file names no account".to_string()) +} + +/// When the token on disk lapses, as epoch seconds. +fn token_expiry() -> Option<f64> { + iso_epoch(&text(&token_entry().ok()?, "expires_at")) +} + +/// The bearer token to ask with, or why the ask cannot even be tried. +/// +/// The expiry is checked here rather than left to the server so a lapsed +/// token costs no request - but the reason is carried out instead of being +/// flattened to None, because "not live" for a token that lapsed an hour +/// ago and "not live" for an endpoint that is down are the same two words +/// for two different things, and only one of them is fixed by waiting. +fn usable_token() -> Result<String, String> { + token_of(&token_entry()?, now()) +} + +/// The same decision, over a value and a clock, so it can be tested without +/// a token on disk and without waiting for one to lapse. +fn token_of(entry: &serde_json::Value, at: f64) -> Result<String, String> { + let key = entry["key"] + .as_str() + .filter(|k| !k.is_empty()) + .ok_or_else(|| "the token file names no account".to_string())? + .to_string(); + if let Some(expiry) = iso_epoch(&text(entry, "expires_at")) { + if expiry <= at { + return Err(format!( + "the token lapsed {} ago - the Grok CLI refreshes it", + left_span(at - expiry) + )); + } + } + Ok(key) +} + +fn fetch_billing(key: &str, seconds: u64) -> Option<serde_json::Value> { + get_json( + BILLING, + &[("Authorization", &format!("Bearer {}", key))], + seconds, + ) +} + +/// How old a reading may be and still be shown as current. +/// +/// Half an hour, the same figure and the same reasoning as Claude's +/// CLAUDE_FRESH_FOR: a credit window that turns over weekly does not move +/// enough in half an hour to mislead anyone. +/// +/// This tab used to mark by *where* a reading came from - `stale` was +/// simply "not from the server" - and that is the mistake claude.rs already +/// wrote down: it flags the fresher of two readings as the doubtful one. +/// Here it was worse, because the live answer was being discarded (see +/// quota_from) and the fallback was a log line eleven days old, so the row +/// said "not live" whether the ping was working or not, and turning the +/// ping on changed nothing a reader could see. +const GROK_FRESH_FOR: f64 = 1800.0; + +/// True when the reading is old enough to be worth flagging, whatever its +/// source. A reading with no timestamp at all is treated as old, because +/// unknown age is not evidence of youth. +fn reading_is_old(taken: Option<f64>) -> bool { + taken.is_none_or(|t| now() - t > GROK_FRESH_FOR) +} + +/// The week split by product, in the order the server lists them. +/// +/// A product with nothing spent omits `usagePercent` exactly as the total +/// does, and for the same reason, so an absent one reads as nought here +/// too. Products are kept even at nought: which of the three is idle is +/// part of the answer, and dropping them would leave a reader unable to +/// tell an unused product from one the server stopped reporting. +fn products_of(cfg: &serde_json::Value) -> Vec<(String, f64)> { + cfg["productUsage"] + .as_array() + .map(|list| { + list.iter() + .filter_map(|entry| { + let name = text(entry, "product"); + if name.is_empty() { + return None; + } + Some((name, val_of(&entry["usagePercent"]).unwrap_or(0.0))) + }) + .collect() + }) + .unwrap_or_default() +} + +/// The billing body in the same shape the log parser produces, so the rest +/// of the tab cannot tell which of the two it is looking at. +fn quota_from(d: &serde_json::Value) -> Option<Quota> { + let cfg = &d["config"]; + let period = &cfg["currentPeriod"]; + // The period is what makes this a reading; the percentage is optional. + // + // It used to be the other way round - no percentage, no reading - and + // that has stopped being true of the endpoint. x.ai no longer sends + // `creditUsagePercent` for unified-billing accounts: both `/v1/billing` + // and `?format=credits` answer 200, name the current weekly period, and + // omit it entirely. Refusing the whole answer for that meant falling + // back to the newest log line, which on this machine was written eleven + // days ago about a window that closed a week before that - a fossil + // shown as current while the server's own answer, naming the window we + // are actually in, was thrown away. + // + // The log parser has always accepted a reading whose percentage is + // absent, for exactly this reason. The two are consistent now. + let start = text(period, "start"); + let end = text(period, "end"); + if start.is_empty() || end.is_empty() { + return None; + } + Some(Quota { + // An absent percentage against a named period means nought, not + // unknown. This is proto3 omitting a scalar at its default, and it + // was mistaken here for the field having been withdrawn. + // + // The evidence is inside a single response. Alongside the credit + // figure the endpoint returns `productUsage`, one entry per product, + // and on this account it reads: + // + // [{"product":"GrokBuild","usagePercent":1.0}, + // {"product":"GrokChat"},{"product":"GrokImagine"}] + // + // The product with usage carries the key; the two at zero omit it, + // in the same array, in the same answer. Watched over time as well: + // this account's weekly window reset at 02:09, read with no + // percentage at all while nothing had been spent, and began + // reporting 1.0 once it had - same endpoint, same headers, same + // token. + // + // So nought is the real reading and not a guess, which is the only + // reason it may be drawn. A period this cannot parse is still + // unknown, and still refused above. + pct: Some(val_of(&cfg["creditUsagePercent"]).unwrap_or(0.0)), + products: products_of(cfg), + taken: Some(now()), + kind: text(period, "type"), + start, + end, + tier: String::new(), + on_demand_used: val_of(&cfg["onDemandUsed"]["val"]), + on_demand_cap: val_of(&cfg["onDemandCap"]["val"]), + prepaid: val_of(&cfg["prepaidBalance"]["val"]), + }) +} + +/// The window we are in now, rolled forward from the one the log recorded. +/// +/// Grok publishes no live quota: the reading is whatever its own CLI last +/// wrote to its log, and that can be weeks old. Once the recorded window has +/// ended, its end date is not a reset to count down to - it is a date that +/// has been and gone, which is why this row read "resetting" for ever +/// instead of counting down to anything. +/// +/// The window rolls forward on its own measured length rather than on an +/// assumed seven days: the server states the period, and a window that +/// turns out to be fortnightly should not be guessed weekly. That makes the +/// answer a calculation rather than an observation, so it is flagged and +/// the screen prints it with a `~`. +/// +/// Returns the recorded end unchanged when there is nothing to roll: no +/// length to roll by, or a window that has not ended yet. +fn window_now(begin: Option<f64>, end: Option<f64>, at: f64) -> (Option<f64>, bool) { + let (Some(b), Some(e)) = (begin, end) else { + return (end, false); + }; + let len = e - b; + if len <= 0.0 || e > at { + return (end, false); + } + let skipped = ((at - e) / len).floor() + 1.0; + (Some(e + skipped * len), true) +} + +pub fn lanes(d: &Data) -> Vec<Lane> { + let Some(q) = d.quota.as_ref() else { + return Vec::new(); + }; + let Some(pct) = q.pct else { + return Vec::new(); + }; + let (begin, end) = (iso_epoch(&q.start), iso_epoch(&q.end)); + // A live reading is of the window we are in, so there is nothing to roll + // forward and nothing to qualify. Only the log needs either. + let (reset, projected) = if d.quota_live { + (end, false) + } else { + window_now(begin, end, now()) + }; + vec![Lane { + label: "credits".into(), + pct, + window_secs: match (begin, end) { + (Some(b), Some(e)) if e > b => Some(e - b), + _ => None, + }, + reset, + // By age, not by source. A figure the server sent four minutes ago + // and one the log recorded thirty seconds ago are both current; a + // live fetch of a reading taken days earlier is not. + stale: reading_is_old(q.taken), + projected, + apart: false, + }] +} + +/// True when nothing is asking the server on the reader's behalf, so the +/// figures move only when they use Grok on this machine. The summary says so +/// under the row; once asking is on, the tab reports the interval instead. +/// Why Grok publishes no bar, when it does not. +/// +/// "No quota published" covers two situations that want opposite things +/// from the reader. Nobody is asking, and they could turn asking on - or +/// the ask is working and x.ai is the one with nothing to report, in which +/// case there is nothing for them to do and a prompt to change a setting +/// would be a wild goose chase. +pub fn why_no_lane(d: &Data) -> &'static str { + if d.quota.is_none() { + return ""; + } + if d.quota_live { + "x.ai answered, and published no credit figure for this period. Accounts on unified billing stopped carrying one; the window it does state is on the GROK tab." + } else { + "" + } +} + +pub fn asks_nobody(d: &Data) -> bool { + d.quota_every <= 0.0 +} + +/// Where the figure came from, in the two states it can be in. +/// +/// Grok is the only agent here that can be reading a file rather than a +/// server, and the file goes stale in silence: the last time it did it +/// showed 23% of a window that had closed, while the account had spent 57% +/// of the one it was in. A percentage nobody dated reads as current. +/// +/// Written for someone who will go and look: it names the file, its age and +/// the settings, rather than explaining what a stale reading is. +/// An interval as a reader would say it: "1h", not left_span's "1h 0m", +/// which is a duration formatter answering a question nobody asked. +fn every(seconds: f64) -> String { + let mins = (seconds.max(60.0) / 60.0).round() as u64; + match (mins / 60, mins % 60) { + (0, m) => format!("{}m", m), + (h, 0) => format!("{}h", h), + (h, m) => format!("{}h {}m", h, m), + } +} + +fn freshness(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let mut out = Vec::new(); + if d.quota_every > 0.0 { + let fresh = !reading_is_old(d.quota.as_ref().and_then(|q| q.taken)); + let ago = now() - d.quota_at; + let last = if d.quota_at <= 0.0 { + "not yet".to_string() + } else if ago < 90.0 { + "just now".to_string() + } else { + format!("{} ago", left_span(ago)) + }; + out.push(tc::seg( + &[ + // Whether the figure is current, which is a question about + // its age. It used to be a question about its source, so a + // working ping still read "not live" for as long as the + // answer it fetched was being discarded. + ( + if fresh { p.ok.as_str() } else { p.warn.as_str() }, + if fresh { " live" } else { " not live" }.to_string(), + ), + ( + p.dim.as_str(), + format!(" · polled x.ai {}, every {}", last, every(d.quota_every)), + ), + // "not live" alone is one phrase for four situations, and + // three of them are fixable by the reader. Saying which + // costs a clause and is the difference between a widget + // that looks broken and one that says what to do. + ( + p.dim.as_str(), + if d.quota_why.is_empty() { + String::new() + } else { + format!(" · {}", d.quota_why) + }, + ), + ], + w - 1, + )); + out.push(String::new()); + return out; + } + // The reading's age, not the file's. The CLI touches that log whenever + // it starts, so a file written minutes ago can still hold a credit + // figure from a fortnight back - and "written 17m ago" beside a + // percentage reads as a fresh percentage. + let closed = d + .quota + .as_ref() + .and_then(|q| iso_epoch(&q.end)) + .filter(|e| *e <= now()) + .map(|e| format!(" · window closed {} ago", left_span(now() - e))) + .unwrap_or_default(); + out.push(tc::seg( + &[ + (p.warn.as_str(), " not live".into()), + (p.dim.as_str(), format!(" · ~/{}{}", LOG, closed)), + ], + w - 1, + )); + out.push(tc::seg( + &[( + p.dim.as_str(), + " Only your own Grok sessions update it. usage.grok_ping polls x.ai instead." + .into(), + )], + w - 1, + )); + out.push(String::new()); + out +} + +/// What the server calls this window, in words a reader has met before. +fn period_name(kind: &str) -> String { + if kind.contains("WEEKLY") { + return "weekly".into(); + } + match kind.replace("USAGE_PERIOD_TYPE_", "").to_lowercase() { + s if s.is_empty() => "current".into(), + s => s, + } +} + +/// A date without its year, kept in the offset it arrived in. +/// +/// iso_day is the widget's parser for these and deliberately does not +/// convert to this machine's zone: a billing window that reads a day +/// earlier here than on the vendor's own page is worse than no window at +/// all. The year goes because both ends of a window share it. +fn short_day(s: &str) -> String { + let full = iso_day(s); + match full.rsplit_once(' ') { + Some((day, _year)) => day.to_string(), + None => full, + } +} + +/// A row from owned pairs, which is what paced_bar and the calendar hand +/// back - seg borrows its colours. +fn seg_of(parts: &[(String, String)], w: usize) -> String { + let refs: Vec<(&str, String)> = parts.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + tc::seg(&refs, w - 1) +} + +fn grok_tab(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let hue = agent_hue("grok"); + let mut rows: Vec<String> = Vec::new(); + // Not filtered on the percentage any more. A reading that names the + // window but not the spend is still a reading, and hiding the whole + // section for it is the failure this repo keeps paying for: the pane + // goes blank and a reader cannot tell an account with no quota from a + // widget that has stopped working. The window, the countdown and the + // reason are all still true; only the bar needs a number. + if let Some(q) = d.quota.as_ref() { + // The one real remaining-quota figure in this widget: everything + // else here counts what was spent. It leads the tab for that + // reason. + let (begin, end) = (iso_epoch(&q.start), iso_epoch(&q.end)); + // The window we are in, not the one the reading came from - the + // heading used the raw end, so a rolled window lost its countdown + // here while the summary still had one. Two screens, one answer. + let (current, rolled) = if d.quota_live { + (end, false) + } else { + window_now(begin, end, now()) + }; + let left = current.map(|e| e - now()).filter(|secs| *secs >= 0.0); + rows.push(tc::seg( + &[ + ( + p.lbl.as_str(), + format!(" ── {} QUOTA ── ", period_name(&q.kind).to_uppercase()), + ), + ( + p.dim.as_str(), + // left_span, not a decimal of a day: "~0.9 days" is + // most of a day away stated in the least useful unit, + // and the reader wanting to know if they can finish + // something before the reset needs hours and minutes. + left.map(|secs| { + format!("resets in {}{}", if rolled { "~" } else { "" }, left_span(secs)) + }) + .unwrap_or_default(), + ), + ], + w - 1, + )); + rows.extend(freshness(d, w, p)); + // A window is only a window if it runs forwards; without both ends + // there is no pace to report, and a mark placed anyway would be a + // claim about a clock nobody read. + let (span, reset) = match (begin, end) { + (Some(b), Some(e)) if e > b => (Some(e - b), Some(e)), + _ => (None, None), + }; + // The bar is the one part that needs a number. Without one the row + // says so in words rather than drawing an empty gauge, which would + // read as nought per cent used. + match q.pct { + Some(pct) => { + let mut line: Vec<(String, String)> = vec![( + pct_colour(pct, hue, p), + format!(" {:<5}", format!("{:.0}%", pct)), + )]; + line.extend(paced_bar( + (pct / 100.0).clamp(0.0, 1.0), + elapsed_of(span, reset), + w.saturating_sub(38).max(10), + hue, + p, + )); + line.push((p.dim.clone(), " credits used".into())); + line.push(pace_cell(lead(pct, span, reset), p)); + rows.push(seg_of(&line, w)); + } + None => rows.push(tc::seg( + &[(p.dim.as_str(), " no credit figure for this period".into())], + w - 1, + )), + } + + let (from, to) = (short_day(&q.start), short_day(&q.end)); + let window = if from.is_empty() || to.is_empty() { + "?".to_string() + } else { + format!("{} → {}", from, to) + }; + let mut extras: Vec<String> = Vec::new(); + if let Some(cap) = q.on_demand_cap.filter(|v| *v != 0.0) { + extras.push(format!( + "on-demand {}/{}", + q.on_demand_used.unwrap_or(0.0), + cap + )); + } + if let Some(prepaid) = q.prepaid.filter(|v| *v != 0.0) { + extras.push(format!("prepaid {}", prepaid)); + } + rows.push(tc::seg( + &[ + (p.dim.as_str(), " window ".into()), + (p.txt.as_str(), window), + ( + p.dim.as_str(), + if extras.is_empty() { + String::new() + } else { + format!(" {}", extras.join(" · ")) + }, + ), + ], + w - 1, + )); + // The same week split by product. Worth a row of its own because + // the bar above is one number for three different things, and which + // of them is spending is the part a reader can act on. + if !q.products.is_empty() { + let split = q + .products + .iter() + .map(|(name, pct)| format!("{} {}", name, pct_text(*pct).trim())) + .collect::<Vec<_>>() + .join(" · "); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " by product ".into()), + (p.txt.as_str(), split), + ], + w - 1, + )); + } + rows.push(String::new()); + } + + // Everything below counts what this machine recorded, so it stops here + // when there is nothing recorded - but the quota above has already been + // drawn, because it is a fact about the account rather than the disk. + if !d.ok { + rows.extend(no_local( + "No Grok sessions on this machine.", + run_hint("grok"), + w, + p, + )); + return rows; + } + + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── TOTALS ── ".into()), + ( + p.dim.as_str(), + format!("{} sessions · newest {} ago", d.sessions, ago(d.last)), + ), + ], + w - 1, + )); + rows.push(tc::seg( + &[ + (p.dim.as_str(), " tokens ".into()), + (p.agent.as_str(), big_num(d.total)), + ( + p.dim.as_str(), + format!(" across {} session files", d.files), + ), + ], + w - 1, + )); + + if let Some(cal) = day_calendar(&d.daily, w, GROK_STEPS, None, p) { + let peak = d.daily.values().cloned().fold(0.0f64, f64::max); + rows.push(String::new()); + rows.push(tc::seg( + &[ + (p.lbl.as_str(), " ── TOKENS / DAY ── ".into()), + (p.dim.as_str(), "peak ".into()), + (p.agent.as_str(), big_num(peak)), + ( + p.dim.as_str(), + format!( + " on {}", + cal.best + .map(|b| format!("{} {}", MONTHS[b.month0() as usize], b.day())) + .unwrap_or_else(|| "--".into()) + ), + ), + ], + w - 1, + )); + for line in &cal.rows { + rows.push(seg_of(line, w)); + } + let mut legend: Vec<(String, String)> = vec![(p.dim.clone(), " Less ".into())]; + legend.extend( + GROK_STEPS + .iter() + .map(|(r, g, b)| (tc::rgb(*r, *g, *b), "█".to_string())), + ); + legend.push((p.dim.clone(), " More".into())); + rows.push(seg_of(&legend, w)); + } + + rows.push(String::new()); + // Where the totals come from, because a running counter summed as + // deltas is not what a reader assumes a token total is. The second + // sentence points at something on screen, so it is only written when + // that something is there. + let mut note = "Totals are a running count per session, summed as deltas so a session \ + spanning days lands on the right one." + .to_string(); + // Either way it is the server's own figure and not an inference - but + // which of the two routes it took is a different sentence, and the note + // named only the log for as long as the log was the only route that + // ever produced one. + if d.quota.is_some() { + note.push_str(if d.quota_live { + " The quota above is the server's own figure, asked for directly - not inferred." + } else { + " The quota above is the server's own figure, read from the client log - not \ + inferred." + }); + } + for line in wrap_text(¬e, w.saturating_sub(4).max(20)) { + rows.push(tc::seg(&[(p.dim.as_str(), format!(" {}", line))], w - 1)); + } + rows +} + +/// Grok states its tier and the kind of period it bills in, and no more. +/// +/// Both arrive on the client log beside the credit percentage, so this +/// costs nothing extra to show. +fn plan_block(d: &Data, w: usize, p: &Palette) -> Vec<String> { + let Some(q) = d.quota.as_ref() else { + return Vec::new(); + }; + let mut pairs: Vec<(String, String)> = Vec::new(); + let kind = q.kind.replace("USAGE_PERIOD_TYPE_", "").to_lowercase(); + if !kind.is_empty() { + pairs.push(("billing period".into(), kind)); + } + // A cap of zero is still a cap the account has, so this asks whether + // the server sent one rather than whether it is spendable. + if let Some(cap) = q.on_demand_cap { + pairs.push(( + "on-demand".into(), + format!("{} of {} used", q.on_demand_used.unwrap_or(0.0), cap), + )); + } + if let Some(prepaid) = q.prepaid { + pairs.push(("prepaid balance".into(), format!("{}", prepaid))); + } + plan_rows(&q.tier, &pairs, w, "", None, "", p) +} + +/// The whole tab: the credit window, what the transcripts recorded, and +/// which subscription that percentage is a percentage of. +/// +/// No METERED section, unlike the other tabs. Grok's events carry one +/// running total with no model on it and no split by priced kind, and +/// input, output and the two cache durations differ in price by up to +/// fifty times - so a total here cannot be costed at all. +pub fn tab(d: &Data, w: usize, _h: usize, _cfg: &Config, p: &Palette) -> Vec<String> { + add_section(grok_tab(d, w, p), plan_block(d, w, p)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A client-log line carrying a credit reading. Invented, but shaped + /// like the real thing down to the microseconds and the numeric offset + /// on the period - a fixture that arrives in a tidier format than the + /// server sends tests a parser nobody has. + const LOG_LINE: &str = concat!( + r#"{"msg":"config","lvl":"info","ctx":{"subscriptionTier":"Test Premium","config":{"#, + r#""creditUsagePercent":42.5,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","#, + r#""start":"2026-08-10T00:00:00.000000+00:00","#, + r#""end":"2026-08-17T00:00:00.000000+00:00"},"#, + r#""onDemandUsed":{"val":"3"},"onDemandCap":{"val":25},"#, + r#""prepaidBalance":{"val":0}}}}"# + ); + + #[test] + fn the_quota_comes_off_the_log_not_the_transcript() { + // A session event carries a running token count and says nothing + // about credits, which is why the log is read at all. + let event = r#"{"totalTokens":1200,"agentTimestampMs":1755302400000}"#; + assert!(newest_quota([event].into_iter()).is_none()); + let q = newest_quota([LOG_LINE].into_iter()).expect("the log line carries a quota"); + assert_eq!(q.pct, Some(42.5)); + assert_eq!(q.tier, "Test Premium"); + assert_eq!(q.kind, "USAGE_PERIOD_TYPE_WEEKLY"); + } + + #[test] + fn an_int64_written_as_a_string_is_still_a_number() { + // The server quotes what will not fit in a JSON number. Read as + // text these read as absent, and the whole block disappears. + let q = newest_quota([LOG_LINE].into_iter()).expect("the log line carries a quota"); + assert_eq!(q.on_demand_used, Some(3.0)); + assert_eq!(q.on_demand_cap, Some(25.0)); + assert_eq!(q.prepaid, Some(0.0)); + } + + #[test] + fn the_newest_period_wins_whichever_line_it_is_on() { + let older = LOG_LINE + .replace("2026-08-10", "2026-08-03") + .replace("42.5", "90"); + for lines in [ + [older.as_str(), LOG_LINE], + [LOG_LINE, older.as_str()], + ] { + let q = newest_quota(lines.into_iter()).expect("one of the two is newest"); + assert_eq!(q.pct, Some(42.5)); + } + } + + #[test] + fn two_readings_of_one_period_settle_on_the_later_line() { + // Same window, different percentages: the log repeats the window + // on every exchange, so later on the file is later in time. + let earlier = LOG_LINE.replace("42.5", "11"); + let q = newest_quota([earlier.as_str(), LOG_LINE].into_iter()).expect("a quota"); + assert_eq!(q.pct, Some(42.5)); + } + + #[test] + fn a_running_total_is_counted_as_deltas() { + // Two events on one day and one on the next. Summed raw this + // would read 1400 rather than 900, and put all of it on one day. + let body = concat!( + r#"{"totalTokens":100,"agentTimestampMs":1755302400000}"#, + "\n", + r#"{"totalTokens":400,"agentTimestampMs":1755306000000}"#, + "\n", + r#"{"totalTokens":900,"agentTimestampMs":1755388800000}"#, + "\n", + ); + let (total, days) = session_days(body); + assert_eq!(total, 900.0); + assert_eq!(days.get(&day_of(1755302400000)), Some(&400.0)); + assert_eq!(days.get(&day_of(1755388800000)), Some(&500.0)); + } + + #[test] + fn a_counter_that_goes_backwards_is_not_spend() { + // Resuming replays earlier events, so the count can fall. A + // decrease is a replay, and the recovery back to the high-water + // mark is not spend either. + let body = concat!( + r#"{"totalTokens":500,"agentTimestampMs":1755302400000}"#, + "\n", + r#"{"totalTokens":200,"agentTimestampMs":1755302400000}"#, + "\n", + r#"{"totalTokens":600,"agentTimestampMs":1755302400000}"#, + "\n", + ); + assert_eq!(session_days(body).0, 600.0); + } + + #[test] + fn an_event_with_no_timestamp_is_left_out_of_the_day_it_cannot_name() { + let body = concat!( + r#"{"totalTokens":100,"agentTimestampMs":1755302400000}"#, + "\n", + r#"{"totalTokens":700}"#, + "\n", + ); + let (total, days) = session_days(body); + // The 600 has no day to land in, and it leaves the total with it + // rather than being banked against whichever day was read last. + assert_eq!(total, 100.0); + assert_eq!(days.len(), 1); + } + + #[test] + fn the_lane_carries_the_window_it_was_measured_over() { + let d = Data { + ok: true, + quota: newest_quota([LOG_LINE].into_iter()), + ..Default::default() + }; + let got = lanes(&d); + assert_eq!(got.len(), 1); + assert_eq!(got[0].label, "credits"); + assert_eq!(got[0].pct, 42.5); + assert_eq!(got[0].window_secs, Some(7.0 * 86400.0)); + // The fixture's window closed on 2026-08-17, so by the time anyone + // runs this it has long since rolled. The reset is therefore a + // moving target and cannot be pinned to a literal - what can be + // pinned is that it is in the future, sits on the fixture's own + // seven-day grid, and is flagged as worked out rather than read. + let end = iso_epoch("2026-08-17T00:00:00.000000+00:00").unwrap(); + let reset = got[0].reset.unwrap(); + assert!(reset > now(), "the reset is behind us again"); + assert!(reset - now() <= 7.0 * 86400.0, "rolled further than one window"); + let steps = (reset - end) / (7.0 * 86400.0); + assert!( + (steps - steps.round()).abs() < 1e-6, + "the rolled window left the grid the recorded one set: {} windows", + steps + ); + assert!(got[0].projected, "a calculated date must say so"); + // From the log, so the percentage belongs to a window that has since + // closed and the row has to say so. This is the case that was wrong + // in the wild: 23% shown as current where the account had spent 57%. + assert!(got[0].stale, "a reading off the disk must not read as live"); + } + + #[test] + fn an_interval_reads_the_way_someone_would_say_it() { + assert_eq!(every(3600.0), "1h"); + assert_eq!(every(1800.0), "30m"); + assert_eq!(every(5400.0), "1h 30m"); + assert_eq!(every(7200.0), "2h"); + // Never zero: a config of 0 would otherwise advertise "every 0m", + // and the poll interval is floored at a minute anyway. + assert_eq!(every(0.0), "1m"); + } + + #[test] + fn a_reading_is_judged_by_its_age_not_by_where_it_came_from() { + // This asserted the opposite until the rule changed: that a reading + // is current "by definition" when it came from the server. That is + // the mistake claude.rs already wrote down - it flags the fresher of + // two readings as the doubtful one - and here it meant a working + // ping still read "not live", because the answer it fetched was + // being discarded and an eleven-day-old log line shown instead. + let base = newest_quota([LOG_LINE].into_iter()).expect("a reading"); + + // Taken a minute ago: current, and its window is the one we are in. + let d = Data { + ok: true, + quota: Some(Quota { taken: Some(now() - 60.0), ..base.clone() }), + quota_live: true, + ..Default::default() + }; + let got = lanes(&d); + assert_eq!(got.len(), 1); + assert!(!got[0].stale, "a minute-old reading is current"); + assert!(!got[0].projected, "a live window was read, not worked out"); + assert_eq!( + got[0].reset, + iso_epoch("2026-08-17T00:00:00.000000+00:00"), + "a live window is reported as the server gave it, not rolled" + ); + + // Fetched just now, but of a reading taken days ago. Still old: + // fetching an old number does not make it a new one. + let d = Data { + ok: true, + quota: Some(Quota { taken: Some(now() - 5.0 * 86400.0), ..base.clone() }), + quota_live: true, + ..Default::default() + }; + assert!(lanes(&d)[0].stale, "a live fetch of an old reading read as current"); + + // And the other direction: nothing was fetched, the log supplied it + // thirty seconds ago, and that is current whatever its source. + let d = Data { + ok: true, + quota: Some(Quota { taken: Some(now() - 30.0), ..base.clone() }), + quota_live: false, + ..Default::default() + }; + assert!(!lanes(&d)[0].stale, "flagged for its source rather than its age"); + + // A reading that cannot say when it was taken is treated as old: + // unknown age is not evidence of youth. + let d = Data { + ok: true, + quota: Some(Quota { taken: None, ..base }), + quota_live: true, + ..Default::default() + }; + assert!(lanes(&d)[0].stale, "an undateable reading passed as current"); + } + + #[test] + fn a_named_period_with_no_percentage_is_nought_used() { + // This asserted `None` for one commit, on the reading that x.ai had + // withdrawn the field for unified-billing accounts. It had not: + // proto3 omits a scalar sitting at its default, so no key means + // nought. The answer's own `productUsage` array settles it - the + // product with usage carries `usagePercent`, the two at nought omit + // it, in the same array of the same response. + let body = serde_json::json!({"config": { + "currentPeriod": { + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "start": "2026-08-26T02:09:41.289406+00:00", + "end": "2026-09-02T02:09:41.289406+00:00" + }, + "onDemandCap": {"val": 0}, "onDemandUsed": {"val": 0}, + "productUsage": [ + {"product": "GrokBuild", "usagePercent": 1.0}, + {"product": "GrokChat"}, + {"product": "GrokImagine"} + ], + }}); + let q = quota_from(&body).expect("a named period is a reading"); + assert_eq!(q.pct, Some(0.0), "an omitted default read as unknown"); + assert_eq!(q.start, "2026-08-26T02:09:41.289406+00:00"); + assert!(q.taken.is_some(), "a live reading knows when it was taken"); + + // A percentage that is present is taken as sent, nought included - + // a real 0.0 on the wire and an omitted one mean the same thing. + let mut with = body.clone(); + with["config"]["creditUsagePercent"] = serde_json::json!(1.0); + assert_eq!(quota_from(&with).unwrap().pct, Some(1.0)); + with["config"]["creditUsagePercent"] = serde_json::json!(0.0); + assert_eq!(quota_from(&with).unwrap().pct, Some(0.0)); + + // An answer naming no period at all is still not a reading: nought + // is only knowable against a window the server stated. + assert!(quota_from(&serde_json::json!({"config": {}})).is_none()); + assert!(quota_from(&serde_json::json!({"config": { + "creditUsagePercent": 5.0 + }})) + .is_none()); + } + + /// The roll itself, on a fixed clock - `lanes` has to ask the real one. + #[test] + fn a_window_that_has_ended_rolls_forward_to_the_one_we_are_in() { + let day = 86400.0; + let (begin, end) = (Some(0.0), Some(7.0 * day)); + // Mid-window: nothing to roll, and nothing claimed. + assert_eq!(window_now(begin, end, 3.0 * day), (Some(7.0 * day), false)); + // One day after it closed: the window we are in ends a week later. + assert_eq!(window_now(begin, end, 8.0 * day), (Some(14.0 * day), true)); + // Five weeks late still lands on the grid, not five weeks ago. + assert_eq!(window_now(begin, end, 36.0 * day), (Some(42.0 * day), true)); + // Exactly on a boundary belongs to the window starting there. + assert_eq!(window_now(begin, end, 14.0 * day), (Some(21.0 * day), true)); + } + + #[test] + fn a_window_with_no_length_is_left_alone() { + // Half a reading is not a grid to roll along, and inventing one + // would put a countdown on screen that nothing supports. + assert_eq!(window_now(None, Some(100.0), 999.0), (Some(100.0), false)); + assert_eq!(window_now(Some(50.0), None, 999.0), (None, false)); + // A window that does not run forwards is not a window. + assert_eq!(window_now(Some(100.0), Some(50.0), 999.0), (Some(50.0), false)); + assert_eq!(window_now(Some(50.0), Some(50.0), 999.0), (Some(50.0), false)); + } + + #[test] + fn an_agent_with_no_quota_publishes_no_lane() { + assert!(lanes(&Data::default()).is_empty()); + // Present but percentless: there is no lane to rank, and yet the + // tier and the billing period are still known, so the reading is + // kept for the SUBSCRIPTION block rather than thrown away. + let bare = LOG_LINE.replace(r#""creditUsagePercent":42.5"#, r#""creditUsagePercent":null"#); + let d = Data { + ok: true, + quota: newest_quota([bare.as_str()].into_iter()), + ..Default::default() + }; + assert!(lanes(&d).is_empty()); + assert_eq!(d.quota.as_ref().map(|q| q.tier.as_str()), Some("Test Premium")); + assert!(!plan_block(&d, 80, &palette()).is_empty()); + } + + #[test] + fn a_lapsed_token_is_reported_as_lapsed_not_as_missing() { + // These were one answer - None - and the tab said "not live" for + // both. Only one of them is the reader's to fix, so they have to + // read differently. + // 2001-09-09T01:46:40Z, so the ISO stamps below are readable. + let at = 1_000_000_000.0; + let live = serde_json::json!({ + "key": "k", "expires_at": "2001-09-09T01:46:40Z" // at + 0, see below + }); + // A token with no expiry at all is usable: the server is the judge. + assert_eq!(token_of(&serde_json::json!({"key": "k"}), at), Ok("k".into())); + + // Lapsed an hour ago. + let gone = serde_json::json!({"key": "k", "expires_at": "2001-09-09T00:46:40Z"}); + let why = token_of(&gone, at).unwrap_err(); + assert!(why.contains("lapsed"), "{}", why); + assert!(why.contains("Grok CLI"), "says what refreshes it: {}", why); + + // Still good for an hour. + let good = serde_json::json!({"key": "k", "expires_at": "2001-09-09T02:46:40Z"}); + assert_eq!(token_of(&good, at), Ok("k".into())); + + // Exactly at the boundary counts as lapsed, not as usable. + assert!(token_of(&live, at).is_err()); + + // No key is a different reason again. + let why = token_of(&serde_json::json!({"expires_at": "x"}), at).unwrap_err(); + assert!(why.contains("no account"), "{}", why); + } + + #[test] + fn the_badge_says_which_of_the_reasons_applies() { + // "not live" on its own was the same two words for a lapsed token, + // a dead endpoint and a null percentage. + let p = palette(); + let d = Data { + quota: newest_quota([LOG_LINE].into_iter()), + quota_live: false, + quota_at: now() - 30.0, + quota_every: 300.0, + quota_why: "the token lapsed 3h ago - the Grok CLI refreshes it".into(), + ..Data::default() + }; + let rows = freshness(&d, 110, &p); + let joined = rows.join("\n"); + assert!(joined.contains("not live"), "{}", joined); + assert!(joined.contains("token lapsed"), "reason missing: {}", joined); + + // And says nothing extra when the reading is the server's. + let ok = Data { quota_live: true, quota_why: String::new(), ..d.clone() }; + let joined = freshness(&ok, 110, &p).join("\n"); + assert!(joined.contains("live"), "{}", joined); + assert!(!joined.contains(" · the"), "invented a reason: {}", joined); + } + + #[test] + fn the_tab_carries_its_subscription_at_every_width_the_wall_uses() { + // The bar's width is what is left after the labels, and this pane + // is dragged narrow on a phone. A panicking tab takes the whole + // widget with it, and a dropped SUBSCRIPTION leaves a percentage + // that is a percentage of nothing stated. + let p = palette(); + let d = Data { + ok: true, + sessions: 2, + files: 3, + total: 12_345.0, + daily: HashMap::from([(NaiveDate::from_ymd_opt(2026, 8, 16).unwrap(), 900.0)]), + last: now(), + quota: newest_quota([LOG_LINE].into_iter()), + quota_live: false, + quota_at: 0.0, + quota_every: 0.0, + quota_why: String::new(), + }; + for w in [40usize, 80, 200] { + let rows = tab(&d, w, 24, &Config::default(), &p); + assert!(rows.iter().any(|r| r.contains("WEEKLY QUOTA")), "width {}", w); + assert!(rows.iter().any(|r| r.contains("SUBSCRIPTION")), "width {}", w); + } + } + + #[test] + fn a_machine_with_no_sessions_still_shows_the_credit_window() { + // The account has a quota and this disk has no transcripts. Both + // facts are true and the tab states both: the window is drawn, and + // the sections that count local work say there is none. Before this + // the log was never read, so the tab said only "No Grok sessions" + // and the summary screen listed Grok as publishing no quota at all. + let p = palette(); + let d = Data { + ok: false, + quota: newest_quota([LOG_LINE].into_iter()), + ..Data::default() + }; + let rows = grok_tab(&d, 90, &p).join(" "); + assert!(rows.contains("QUOTA"), "{}", rows); + assert!(rows.contains("42%") || rows.contains("43%"), "{}", rows); + assert!(rows.contains("No Grok sessions"), "{}", rows); + // And it publishes a lane, so the summary does not disagree with + // the tab about whether Grok has a quota. + assert_eq!(lanes(&d).len(), 1); + } + + #[test] + fn a_period_the_server_did_not_name_is_still_labelled() { + assert_eq!(period_name("USAGE_PERIOD_TYPE_WEEKLY"), "weekly"); + assert_eq!(period_name("USAGE_PERIOD_TYPE_MONTHLY"), "monthly"); + assert_eq!(period_name(""), "current"); + } + + #[test] + fn a_turn_is_filed_under_the_day_it_was_that_day_here() { + // 1755388200000 is 23:30 UTC. East of Greenwich that is already + // tomorrow locally, west of it the same evening - either way the + // reader must agree with the wall clock of whoever is reading the + // pane, because the three calendars drawn beside this one do. + let body = concat!( + r#"{"totalTokens":0,"agentTimestampMs":1755388200000}"#, + "\n", + r#"{"totalTokens":700,"agentTimestampMs":1755388200000}"#, + "\n", + ); + let (_, days) = session_days(body); + let local = Local + .timestamp_millis_opt(1755388200000) + .single() + .expect("a fixed timestamp") + .date_naive() + .to_string(); + assert_eq!(days.get(&local), Some(&700.0)); + // And nothing was filed under the UTC day, unless this machine is + // on UTC and the two are the same date. + let utc = chrono::Utc + .timestamp_millis_opt(1755388200000) + .single() + .expect("a fixed timestamp") + .date_naive() + .to_string(); + if utc != local { + assert_eq!(days.get(&utc), None); + } + } + + /// The day the reader will file a stamp under - local, matching the + /// reader, so this asserts the bucketing rather than the zone this + /// machine happens to run in. + fn day_of(ms: i64) -> String { + Local + .timestamp_millis_opt(ms) + .single() + .expect("a fixed timestamp") + .date_naive() + .to_string() + } +} diff --git a/widgets/src/bin/usage/shared.rs b/widgets/src/bin/usage/shared.rs new file mode 100644 index 0000000..1c1d24f --- /dev/null +++ b/widgets/src/bin/usage/shared.rs @@ -0,0 +1,368 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! What every vendor reader needs: the caches, the file walking, and the +//! one shape the summary screen understands. +//! +//! This is a toolkit rather than a caller, so an entry nobody has +//! reached for yet is not dead code - it is the next reader's. +#![allow(dead_code)] + +use std::collections::HashMap; + +use crate::*; + +/// Readings held between passes, so a finished transcript is parsed once +/// and a quota endpoint is not asked six times a minute. +#[derive(Default)] +pub struct Caches { + /// path -> ((mtime, size), records keyed by uuid) + pub transcripts: HashMap<String, ((u64, u64), HashMap<String, (String, String, Tokens)>)>, + /// key -> (when, value, ttl) + pub live: HashMap<String, (f64, Option<serde_json::Value>, f64)>, + /// key -> refusals in a row, which is what the backoff doubles on. + /// Cleared the moment one gets through. + pub fails: HashMap<String, u32>, +} + +pub const LIVE_TTL: f64 = 120.0; +/// Where a refusal starts waiting, and where it stops. +/// +/// Two minutes for the first, because one failure is usually nothing - a +/// dropped connection, a server having a moment - and waiting five for it +/// would make a blip look like an outage. Doubling after that, because a +/// refusal that keeps coming is not a blip, and the flat hold this replaced +/// walked back in at the same rate however many times it was turned away. +/// That is what sustains a rate limit rather than clearing it. +/// +/// 120, 240, 480, 960, then 1800 and no further. The ceiling is thirty +/// minutes because that is when the screen starts calling a reading old: +/// backing off past the point where the reader is told something is wrong +/// would leave the widget quietly not trying. +pub const BACKOFF_FROM: f64 = 120.0; +pub const BACKOFF_MAX: f64 = 1800.0; + +/// How long to wait after `n` refusals in a row. +pub fn backoff(n: u32) -> f64 { + let doubled = BACKOFF_FROM * 2f64.powi(n.saturating_sub(1).min(16) as i32); + doubled.min(BACKOFF_MAX) +} +/// A plan does not change between refreshes; the windows do. +pub const PLAN_TTL: f64 = 3600.0; + +/// Hold a reading for a while, but never hold a failure that long. +/// +/// The pane redraws every thirty seconds; these windows move over hours. A +/// refusal is held too, so a dead endpoint is retried occasionally rather +/// than on every frame - and held longer each time it is refused again, so +/// an endpoint saying "too often" is not answered at the same rate that +/// provoked it. +pub fn cached<F>(caches: &mut Caches, key: &str, ttl: f64, fetch: F) -> Option<serde_json::Value> +where + F: FnOnce() -> Option<serde_json::Value>, +{ + let at = now(); + if let Some((when, value, held)) = caches.live.get(key) { + if at - when < *held { + return value.clone(); + } + } + let value = fetch(); + let held = if value.is_some() { + caches.fails.remove(key); + ttl + } else { + let n = caches.fails.entry(key.to_string()).or_insert(0); + *n += 1; + // Never longer than the interval itself would have been, for a + // reading asked for hourly: backing an hourly plan read off to + // thirty minutes is fine, but it must not exceed the hour. + backoff(*n).min(ttl.max(BACKOFF_FROM)) + }; + caches.live.insert(key.to_string(), (at, value.clone(), held)); + value +} + +pub fn read_json(path: &str) -> Option<serde_json::Value> { + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok() +} + +/// Every file under a directory whose name ends in `suffix`. +/// +/// Recursive on purpose: several agents nest their transcripts two or three +/// levels down, and globbing one level deep silently missed most of them. +pub fn walk(dir: &str, suffix: &str, out: &mut Vec<String>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = path.to_string_lossy().to_string(); + match entry.file_type() { + Ok(t) if t.is_dir() => walk(&name, suffix, out), + Ok(t) if t.is_file() && name.ends_with(suffix) => out.push(name), + _ => {} + } + } +} + +/// Newest first, by modification time. +pub fn newest_first(paths: Vec<String>) -> Vec<String> { + use std::os::unix::fs::MetadataExt; + let mut with_time: Vec<(u64, String)> = paths + .into_iter() + .filter_map(|path| { + let meta = std::fs::metadata(&path).ok()?; + Some((meta.mtime() as u64, path)) + }) + .collect(); + with_time.sort_by(|a, b| b.0.cmp(&a.0)); + with_time.into_iter().map(|(_, p)| p).collect() +} + +/// The last `size` bytes of a file, as lines. +/// +/// A rollout carries its running total on every token_count event, so the +/// newest one is all that is needed - reading thirty megabytes per refresh +/// to learn a number that is repeated at the end would be daft. +pub fn tail_lines(path: &str, size: u64) -> Vec<String> { + use std::io::{Read, Seek, SeekFrom}; + let Ok(mut f) = std::fs::File::open(path) else { + return Vec::new(); + }; + let end = f.seek(SeekFrom::End(0)).unwrap_or(0); + if f.seek(SeekFrom::Start(end.saturating_sub(size))).is_err() { + return Vec::new(); + } + let mut buf = Vec::new(); + if f.read_to_end(&mut buf).is_err() { + return Vec::new(); + } + String::from_utf8_lossy(&buf) + .split('\n') + .map(String::from) + .collect() +} + +/// Enough to reach the last running total in a rollout. +pub const TAIL: u64 = 256 * 1024; + +/// One quota an agent publishes, flattened to the four numbers the summary +/// screen can compare across agents. +/// +/// Read as data rather than borrowed from each tab's renderer, because +/// those render six different shapes - Cursor's coloured lanes, +/// Antigravity's groups, Codex's per-feature windows - and only these are +/// common to all of them. +#[derive(Clone, Debug, Default)] +pub struct Lane { + pub label: String, + pub pct: f64, + /// How long the window is, where the agent says. + pub window_secs: Option<f64>, + /// When it resets, as epoch seconds. + pub reset: Option<f64>, + /// True when this came from a cache rather than from the agent just + /// now. A number nobody labelled as old reads as current. + pub stale: bool, + /// True when `reset` was worked out rather than read - a window rolled + /// forward from an older one on the length the agent stated. It is + /// shown with a `~`, because a date this widget calculated and a date + /// the server sent are not the same kind of fact. + pub projected: bool, + /// True when this lane is not part of the group above it and should be + /// separated from it. Cursor's Grok Bot allowance is the case this + /// exists for: three plan lanes on the monthly cycle, then a weekly + /// allowance that is not a fourth slice of them. Carried on the lane so + /// the agent's own tab and the summary cannot drift apart on it. + pub apart: bool, +} + +/// What a refused request said, in words a reader can act on. +/// +/// `tc::get` returns curl's own message, which for `--fail` names the status +/// - "curl: (22) The requested URL returned error: 429". Every caller here +/// threw that away with `.ok()?`, so the screen could say a reading was old +/// but never why, and an hour went into checking a credential that was fine +/// while the server had already said "too many requests". +/// +/// The code is read out of that message rather than guessed at. Anything +/// unrecognised is passed through as the reason it was, which is still more +/// than nothing. +pub fn refusal(said: &str) -> String { + let code = said + .rsplit_once("error: ") + .and_then(|(_, tail)| tail.split_whitespace().next()) + .and_then(|c| c.parse::<u16>().ok()); + match code { + Some(429) => "too many requests - something else is polling the same token".into(), + Some(401) | Some(403) => "the token was refused - the agent may need signing in again".into(), + Some(404) => "the endpoint is gone".into(), + Some(c) if (500..600).contains(&c) => format!("the server answered {}", c), + Some(c) => format!("the server answered {}", c), + // A timeout or a dead network never reaches a status at all. + None if said.contains("timed out") || said.contains("Timeout") => { + "the request timed out".into() + } + None if said.contains("Could not resolve") || said.contains("Failed to connect") => { + "could not reach it".into() + } + None => said.trim_start_matches("curl: ").to_string(), + } +} + +/// An HTTPS GET carrying a bearer token, returning parsed JSON. +/// +/// The token goes to curl on its standard input, never in its arguments: +/// /proc/<pid>/cmdline is world-readable, so an argument is a secret handed +/// to every user on the box for as long as the request lasts. +pub fn get_json(url: &str, headers: &[(&str, &str)], seconds: u64) -> Option<serde_json::Value> { + serde_json::from_str(&tc::get(url, headers, seconds).ok()?).ok() +} + +/// An HTTPS POST of a JSON body carrying a bearer token. +pub fn post_json( + url: &str, + headers: &[(&str, &str)], + body: &str, + seconds: u64, +) -> Option<serde_json::Value> { + post_json_said(url, headers, body, seconds).ok() +} + +/// The same POST, keeping why it failed rather than discarding it. +pub fn post_json_said( + url: &str, + headers: &[(&str, &str)], + body: &str, + seconds: u64, +) -> Result<serde_json::Value, String> { + let (text, _) = tc::post_json(url, headers, body, seconds).map_err(|said| refusal(&said))?; + serde_json::from_str(&text).map_err(|e| format!("unreadable answer: {}", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The string this was built from is the one curl actually produced + /// against api.anthropic.com while three widgets shared a token. + #[test] + fn a_refusal_says_which_refusal_it_was() { + let real = "curl: (22) The requested URL returned error: 429"; + assert!( + refusal(real).contains("too many requests"), + "the message that cost an hour: {:?}", + refusal(real) + ); + assert!(refusal(real).contains("same token"), "and what to do about it"); + + for (said, want) in [ + ("curl: (22) The requested URL returned error: 401", "token was refused"), + ("curl: (22) The requested URL returned error: 403", "token was refused"), + ("curl: (22) The requested URL returned error: 503", "answered 503"), + ("curl: (28) Operation timed out after 20000 ms", "timed out"), + ("curl: (6) Could not resolve host: api.anthropic.com", "could not reach it"), + ] { + assert!(refusal(said).contains(want), "{:?} -> {:?}", said, refusal(said)); + } + + // Anything unrecognised comes through as itself rather than as + // nothing, which is what the old .ok()? made of all of them. + let odd = "curl: (35) SSL connect error"; + assert_eq!(refusal(odd), "(35) SSL connect error"); + assert!(!refusal(odd).is_empty()); + } + + /// The sequence, and why it is not a flat hold: the one it replaced + /// walked back in at the same interval however many times it was turned + /// away, which sustains a rate limit rather than clearing it. + #[test] + fn a_refusal_waits_longer_each_time_it_is_refused() { + assert_eq!(backoff(1), 120.0, "one failure is usually nothing"); + assert_eq!(backoff(2), 240.0); + assert_eq!(backoff(3), 480.0); + assert_eq!(backoff(4), 960.0); + assert_eq!(backoff(5), BACKOFF_MAX, "and then it stops doubling"); + assert_eq!(backoff(50), BACKOFF_MAX, "including well past the point of doubling"); + // Never zero and never negative, whatever it is handed. + assert_eq!(backoff(0), 120.0); + for n in 0..40 { + let w = backoff(n); + assert!(w >= BACKOFF_FROM && w <= BACKOFF_MAX, "n={} gave {}", n, w); + } + // Strictly growing until the ceiling, which is the whole point. + for n in 1..5 { + assert!(backoff(n) < backoff(n + 1), "n={} did not grow", n); + } + } + + #[test] + fn the_hold_grows_across_calls_and_a_success_forgets_them() { + let mut caches = Caches::default(); + let held = |c: &Caches| c.live.get("probe").map(|(_, _, h)| *h).unwrap(); + + for (call, want) in [(1, 120.0), (2, 240.0), (3, 480.0)] { + // Force the hold to have lapsed, so the next call really asks. + caches.live.remove("probe"); + assert!(cached(&mut caches, "probe", 900.0, || None).is_none()); + assert_eq!(held(&caches), want, "refusal {}", call); + } + + // One that gets through clears the tally, so the next blip starts + // from two minutes again rather than from eight. + caches.live.remove("probe"); + assert!(cached(&mut caches, "probe", 900.0, || Some(serde_json::json!(1))).is_some()); + assert_eq!(held(&caches), 900.0, "a good reading is held for its own interval"); + caches.live.remove("probe"); + assert!(cached(&mut caches, "probe", 900.0, || None).is_none()); + assert_eq!(held(&caches), 120.0, "the count did not reset on success"); + } + + /// An hourly reading may back off, but not past its own interval - and a + /// two-minute one is never held for longer than the backoff says. + #[test] + fn the_backoff_never_outlasts_the_interval_it_belongs_to() { + for ttl in [30.0, 120.0, 300.0, 900.0, 3600.0] { + let mut caches = Caches::default(); + let key = format!("probe-{}", ttl); + assert!(cached(&mut caches, &key, ttl, || None).is_none()); + let (_, _, held) = caches.live.get(&key).unwrap(); + assert!( + *held <= ttl.max(BACKOFF_FROM), + "ttl {}: held {}s, past the interval it belongs to", + ttl, + held + ); + assert!(*held <= BACKOFF_MAX, "ttl {}: held {}s", ttl, held); + } + } + + #[test] + fn a_good_reading_is_held_for_its_full_interval() { + let mut caches = Caches::default(); + let one = cached(&mut caches, "probe", 300.0, || Some(serde_json::json!(1))); + assert_eq!(one, Some(serde_json::json!(1))); + // The second call must not reach the fetcher at all - that is the + // whole point of the hold, and what keeps the endpoint's rate down. + let two = cached(&mut caches, "probe", 300.0, || { + panic!("asked again inside the hold") + }); + assert_eq!(two, Some(serde_json::json!(1))); + let (_, _, held) = caches.live.get("probe").unwrap(); + assert_eq!(*held, 300.0); + } +} diff --git a/widgets/src/bin/usage/vendors.rs b/widgets/src/bin/usage/vendors.rs new file mode 100644 index 0000000..7b35872 --- /dev/null +++ b/widgets/src/bin/usage/vendors.rs @@ -0,0 +1,529 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Which reader answers for which tab, and the one screen that spans them. +//! +//! Each agent keeps its own file and its own shape, because they do not +//! agree on what usage even means. Only the quota lanes are common, and +//! only because the summary screen has to compare them. + +use std::collections::HashMap; + +use toys_core as tc; + +use crate::shared::*; +use crate::*; + +#[derive(Clone, Default)] +pub struct State { + pub claude: crate::claude::Data, + pub codex: crate::codex::Data, + pub cursor: crate::cursor::Data, + pub grok: crate::grok::Data, + pub copilot: crate::copilot::Data, + pub antigravity: crate::antigravity::Data, + pub installed: HashMap<String, Presence>, + pub fetched: f64, + pub err: String, +} + +pub fn read_all(caches: &mut Caches, cfg: &Config) -> State { + State { + claude: crate::claude::read(caches, cfg), + codex: crate::codex::read(caches, cfg), + cursor: crate::cursor::read(caches, cfg), + grok: crate::grok::read(caches, cfg), + copilot: crate::copilot::read(caches, cfg), + antigravity: crate::antigravity::read(caches, cfg), + installed: detect_agents(), + fetched: 0.0, + err: String::new(), + } +} + +/// Every quota an agent publishes, in the one shape they can be compared in. +fn lanes_of(name: &str, s: &State) -> Vec<Lane> { + match name { + "claude" => crate::claude::lanes(&s.claude), + "codex" => crate::codex::lanes(&s.codex), + "cursor" => crate::cursor::lanes(&s.cursor), + "grok" => crate::grok::lanes(&s.grok), + "copilot" => crate::copilot::lanes(&s.copilot), + "antigravity" => crate::antigravity::lanes(&s.antigravity), + _ => Vec::new(), + } +} + +/// Every agent's quotas on one screen, worst first. +/// +/// Not a concatenation of the other tabs: those answer "how am I using this +/// agent", and this answers the only question that spans them - what runs +/// out first. An agent that publishes no quota is named at the bottom +/// instead of being silently missing. +/// Order provider groups by the lane closest to running out. +/// +/// A provider with one lane at 88% outranks one whose lanes all sit at 40%, +/// however many of them there are: the structure says who owns what, the +/// ordering answers which one stops working first. +/// +/// Lifted out of `summary_tab` because its test sorted a vector built in the +/// test body with a comparator written in the test body, so this ordering - +/// the whole point of that screen - was never run by it. +fn rank_by_worst_lane<T>(groups: &mut [(T, Vec<Lane>)]) { + let worst = |g: &Vec<Lane>| g.iter().map(|l| l.pct).fold(0.0f64, f64::max); + groups.sort_by(|a, b| worst(&b.1).total_cmp(&worst(&a.1))); +} + +/// The agents publishing nothing, each under its own name. +/// +/// The order used to be the other way round: one "No quota published by: +/// grok, antigravity." and then, below it, the headings explaining them. +/// That reads backwards - the reader meets a list of names and has to carry +/// them down to the paragraphs - and it stated the same fact twice for +/// every agent that had a reason, because each reason already opens by +/// saying there is no quota. +/// +/// So the roll-call is what is left over. Only agents with nothing to say +/// appear in it, and when every quiet agent has explained itself there is +/// no such line at all. +/// +/// Split out from summary_tab because the State it needs cannot be built +/// from another module - every agent's Data keeps its fields private - so +/// this is the only shape the ordering is testable in. +fn quiet_block(said: &[(&str, String, bool)], w: usize, p: &Palette) -> Vec<String> { + let mut rows = Vec::new(); + let mut unexplained: Vec<&str> = Vec::new(); + for (name, note, warn) in said { + if note.is_empty() { + unexplained.push(name); + continue; + } + rows.push(tc::seg( + &[(p.lbl.as_str(), format!(" {}", name.to_uppercase()))], + w - 1, + )); + let tone = if *warn { p.warn.as_str() } else { p.dim.as_str() }; + rows.extend( + wrap_text(note, w.saturating_sub(5).max(20)) + .into_iter() + .map(|l| tc::seg(&[(tone, format!(" {}", l))], w - 1)), + ); + rows.push(String::new()); + } + if !unexplained.is_empty() { + rows.extend(no_local( + &format!("No quota published by: {}.", unexplained.join(", ")), + "", + w, + p, + )); + } + rows +} + +fn summary_tab(s: &State, w: usize, p: &Palette) -> Vec<String> { + let mut groups: Vec<(&str, Vec<Lane>)> = Vec::new(); + let mut quiet: Vec<&str> = Vec::new(); + for name in ORDER { + let got = lanes_of(name, s); + if got.is_empty() { + quiet.push(name); + } else { + groups.push((name, got)); + } + } + if groups.is_empty() { + return no_local("No agent is publishing a quota right now.", "", w, p); + } + // Grouped by provider, but the groups are ordered by their worst lane: + // the structure says who owns what, the ordering still answers which + // one runs out first. + rank_by_worst_lane(&mut groups); + let total: usize = groups.iter().map(|(_, g)| g.len()).sum(); + let label_w = groups + .iter() + .flat_map(|(_, g)| g.iter()) + .map(|l| l.label.chars().count()) + .max() + .unwrap_or(8) + .min(16); + let mut head = format!("{} limits across {} agents", total, groups.len()); + // Sized against the suffix actually being added, so changing the + // wording cannot quietly start clipping the line. + let suffix = " · ranked by usage"; + if 14 + head.len() + suffix.len() <= w - 1 { + head += suffix; + } + let mut rows = vec![tc::seg( + &[ + (p.lbl.as_str(), " ── QUOTAS ── ".into()), + (p.dim.as_str(), head), + ], + w - 1, + )]; + // 2 lead + label + 1 + pct(6) + pace(6). The reset needs 16 more and is + // the first thing dropped, being the only part a reader can infer from + // the bar beside it - but a stale marker is not droppable, since a + // number nobody labelled as old reads as current. + // + // Staleness is a mark beside the bar rather than words in this cell: + // spelling it out cost the countdown, and a cached reading of a window + // still open has a real countdown worth keeping. One cell for the star, + // one line at the foot saying what it means. + let any_stale = groups.iter().flat_map(|(_, g)| g.iter()).any(|l| l.stale); + // Three more when anything is cached: two for the star's column and one + // for the tilde the pace figure grows. Counted rather than guessed - the + // pace cell is four wide for "+40%" and five for "~+40%". + let fixed = 15 + label_w + usize::from(any_stale) * 3; + let show_reset = (w - 1).saturating_sub(fixed + 8) >= 16; + let tail = if show_reset { 16 } else { 0 }; + let bar_room = (w - 1).saturating_sub(fixed + tail).max(8); + for (i, (name, lanes)) in groups.iter().enumerate() { + if i > 0 { + rows.push(String::new()); + } + let hue = agent_hue(name); + rows.push(tc::seg( + &[( + &hue.map(|(r, g, b)| tc::rgb(r, g, b)).unwrap_or_else(|| p.txt.clone()), + format!(" {}", name.to_uppercase()), + )], + w - 1, + )); + // Ranked by usage, except where the agent's own order already means + // something. Claude's five-hour window sits inside its weekly one, + // which contains the model-scoped limit in turn; Cursor's three plan + // lanes are a widening scope - api inside auto inside included - + // followed by an allowance that is not part of them at all. For both, + // reading them in the agent's order says more than reading them by + // percentage, and percentage reorders itself as the numbers move, so + // the bar under the cursor is not the one that was there a refresh + // ago. It also made this screen disagree with the agent's own tab + // about the order of the very same bars. + let mut inner = lanes.clone(); + if !matches!(*name, "claude" | "cursor") { + inner.sort_by(|a, b| b.pct.total_cmp(&a.pct)); + } + for lane in &inner { + // The same break the agent's tab draws, for the same reason. + if lane.apart { + rows.push(String::new()); + } + let used = (lane.pct / 100.0).clamp(0.0, 1.0); + // "cached" used to replace the countdown outright, which threw + // away a fact to report an adjective. A reading a few minutes old + // still describes the window we are in: its percentage is real, + // its reset is real, and the burn between them is worth having. + // Cached is a note on the end of that, not a substitute for it. + // + // A cached reading whose window has *closed* is the other case, + // and it is the one Claude was in - a figure fetched nine days + // ago, for a window that ended on the sixteenth. There is no + // countdown there to keep, and no burn either: what has been + // spent in the current window is exactly what nobody knows. + let ahead = lane.reset.map(|r| r - now()); + let (when, tone) = if !show_reset { + (String::new(), p.dim.clone()) + } else { + match ahead { + Some(left) if left > 0.0 => ( + format!( + " {}{}", + if lane.projected { "~" } else { "" }, + left_span(left) + ), + if lane.stale { p.warn.clone() } else { p.dim.clone() }, + ), + Some(_) => (" resetting".to_string(), p.dim.clone()), + None => (String::new(), p.dim.clone()), + } + }; + // A projected window is one we rolled forward because the + // agent's own reading had expired - so the percentage beside it + // was measured in a window that has since closed and reset. + // + // The countdown survives that: the grid the window sits on is + // still the grid. The pace does not. Pace is usage against time + // elapsed, and how much of *this* window has been used is + // exactly what nobody knows - computing it from the last + // window's figure produces a confident number about a quantity + // that was never measured. + // Shown for a cached reading too, marked rather than withheld: a + // blank cell tells the reader nothing, and the percentage it + // rests on is on the same row with a star beside it. + let closed = ahead.is_some_and(|left| left <= 0.0); + let guessed = lane.stale || lane.projected || closed; + let cushion = lead(lane.pct, lane.window_secs, lane.reset); + let (pace_colour, pace_txt) = pace_cell_of(cushion, guessed, p); + let mut line: Vec<(String, String)> = vec![( + p.dim.clone(), + format!(" {} ", tc::pad(&lane.label, label_w)), + )]; + line.extend(paced_bar( + used, + elapsed_of(lane.window_secs, lane.reset), + bar_room, + hue, + p, + )); + if any_stale { + // Held for every lane, so the percentages stay in one + // column whether the row beside them is cached or not. + line.push(( + p.warn.clone(), + if lane.stale { " *".into() } else { " ".to_string() }, + )); + } + line.push((pct_colour(lane.pct, hue, p), pct_text(lane.pct))); + line.push((pace_colour, pace_txt)); + line.push((tone, when)); + let refs: Vec<(&str, String)> = + line.iter().map(|(c, t)| (c.as_str(), t.clone())).collect(); + rows.push(tc::seg(&refs, w - 1)); + } + // Grok alone can be reading a file rather than a server, and a + // reader who does not know that has no way to tell this row from + // the five above it. Said here only while nothing is asking on + // their behalf - once it is, the tab reports the interval and this + // line would be repeating a setting back at them. + if *name == "grok" && crate::grok::asks_nobody(&s.grok) { + // Two lines because both halves are worth having and neither + // fits beside the other at the widths these panes are dragged + // to: what the number is, and what to do about it. Clipping one + // to keep them on a single row would leave "usage.grok_pin", + // which names a setting that does not exist. + rows.push(tc::seg( + &[ + (p.warn.as_str(), " not live".into()), + ( + p.dim.as_str(), + " · only your own Grok sessions update this".into(), + ), + ], + w - 1, + )); + rows.push(tc::seg( + &[( + p.dim.as_str(), + " Set usage.grok_ping in config.json to poll x.ai instead.".into(), + )], + w - 1, + )); + } + } + if any_stale { + rows.push(String::new()); + rows.extend( + wrap_text( + "cached - the agent's own last reading rather than one fetched just \ + now. Its own tab says when it was taken, and why.", + w.saturating_sub(6).max(20), + ) + .into_iter() + .enumerate() + .map(|(i, line)| { + tc::seg( + &[ + (p.warn.as_str(), if i == 0 { " * " } else { " " }.into()), + (p.dim.as_str(), line), + ], + w - 1, + ) + }), + ); + } + if !quiet.is_empty() { + rows.push(String::new()); + let mut said: Vec<(&str, String, bool)> = Vec::new(); + for name in &quiet { + match *name { + "grok" => said.push((name, crate::grok::why_no_lane(&s.grok).to_string(), false)), + // Antigravity's can be a credential that has lapsed, which + // is the reader's to fix, so it keeps the warning tone. + "antigravity" => { + said.push((name, crate::antigravity::why_no_lane(&s.antigravity), true)) + } + _ => said.push((name, String::new(), false)), + } + } + rows.extend(quiet_block(&said, w, p)); + } + rows +} + +pub fn tab_body( + name: &str, + s: &State, + w: usize, + h: usize, + cfg: &Config, + p: &Palette, +) -> Vec<String> { + match name { + SUMMARY_TAB => summary_tab(s, w, p), + "claude" => crate::claude::tab(&s.claude, w, h, cfg, p), + "codex" => crate::codex::tab(&s.codex, w, h, cfg, p), + "cursor" => crate::cursor::tab(&s.cursor, w, h, cfg, p), + "grok" => crate::grok::tab(&s.grok, w, h, cfg, p), + "copilot" => crate::copilot::tab(&s.copilot, w, h, cfg, p), + "antigravity" => crate::antigravity::tab(&s.antigravity, w, h, cfg, p), + other => unknown(other, &s.installed, w, p), + } +} + +/// A backstop for an agent added to the list without a reader: it says so +/// rather than raising in the draw loop. +fn unknown(name: &str, installed: &HashMap<String, Presence>, w: usize, p: &Palette) -> Vec<String> { + let (label, _, _) = agent_spec(name); + let have = installed.get(name).is_some_and(|x| x.present); + let mut rows = vec![ + tc::seg( + &[ + (p.lbl.as_str(), format!(" ── {} ── ", label.to_uppercase())), + ( + if have { p.ok.as_str() } else { p.dim.as_str() }, + if have { "installed" } else { "not installed" }.into(), + ), + ], + w - 1, + ), + String::new(), + ]; + for line in wrap_text( + "No reader for this agent. Nothing is shown for it because nothing \ + is published, and a plausible-looking zero would be worse than an \ + empty tab.", + w.saturating_sub(4).max(20), + ) { + rows.push(tc::seg(&[(p.dim.as_str(), format!(" {}", line))], w - 1)); + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Rendered rows with the colour escapes stripped, so a test can read + /// what is on screen rather than how it was painted. + fn plain(rows: &[String]) -> Vec<String> { + let strip = |s: &String| { + let mut out = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + for c in chars.by_ref() { + if c.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out.trim_end().to_string() + }; + rows.iter().map(strip).collect() + } + + #[test] + fn a_quiet_agent_is_explained_under_its_own_name() { + let p = palette(); + let said = vec![( + "antigravity", + "no quota - it publishes none to any server.".to_string(), + true, + )]; + let rows = plain(&quiet_block(&said, 90, &p)); + let head = rows + .iter() + .position(|r| r.contains("ANTIGRAVITY")) + .expect("a heading"); + let line = rows + .iter() + .position(|r| r.contains("publishes none")) + .expect("the reason"); + assert!(head < line, "the sentence came before the name it is about:\n{:#?}", rows); + + // And when every quiet agent has said why, the roll-call that used + // to lead is gone rather than repeating them. + assert!( + !rows.iter().any(|r| r.contains("No quota published by")), + "named twice:\n{:#?}", + rows + ); + } + + #[test] + fn an_agent_with_nothing_to_say_is_still_named() { + // The roll-call is not dropped, only reduced to what is left. + let p = palette(); + let said = vec![ + ("antigravity", "no quota - the app is closed.".to_string(), true), + ("copilot", String::new(), false), + ]; + let rows = plain(&quiet_block(&said, 90, &p)); + let roll = rows + .iter() + .find(|r| r.contains("No quota published by")) + .expect("a roll-call for the one with no reason"); + assert!(roll.contains("copilot"), "{}", roll); + assert!(!roll.contains("antigravity"), "explained and listed: {}", roll); + // The explained one still leads with its heading. + let head = rows.iter().position(|r| r.contains("ANTIGRAVITY")).unwrap(); + let rollat = rows.iter().position(|r| r.contains("No quota published by")).unwrap(); + assert!(head < rollat, "roll-call above the explanations:\n{:#?}", rows); + } + + #[test] + fn an_agent_with_no_quota_is_named_rather_than_dropped() { + // Six agents, none publishing anything: the screen says so instead + // of rendering an empty box. + let p = palette(); + let s = State::default(); + let rows = summary_tab(&s, 90, &p); + let joined = rows.join(" "); + assert!(joined.contains("No agent is publishing a quota")); + } + + #[test] + fn the_summary_ranks_the_worst_agent_first() { + // Deliberately built rather than read from disk: the ordering is + // the whole point of this screen and must not depend on what this + // machine happens to have installed today. + let lane = |pct: f64| Lane { + label: String::new(), + pct, + window_secs: None, + reset: None, + stale: false, + projected: false, + apart: false, + }; + // grok has more lanes and a higher total, and still ranks below the + // provider with the single worst one - which a flat sort by + // percentage would get wrong, and which the old test-local + // comparator could not have exercised at all. + let mut groups = vec![ + ("claude", vec![lane(12.0)]), + ("grok", vec![lane(40.0), lane(39.0), lane(38.0)]), + ("codex", vec![lane(88.0)]), + ]; + rank_by_worst_lane(&mut groups); + let order: Vec<&str> = groups.iter().map(|(n, _)| *n).collect(); + assert_eq!(order, vec!["codex", "grok", "claude"]); + } +} diff --git a/widgets/src/bin/usage_help.txt b/widgets/src/bin/usage_help.txt new file mode 100644 index 0000000..213961b --- /dev/null +++ b/widgets/src/bin/usage_help.txt @@ -0,0 +1,18 @@ +How much the coding agents on this machine have been used. + +One tab per agent, because they do not agree on what usage even means: one +counts tokens, another counts lines it wrote, and several publish nothing at +all outside their own session. A single table would need a shared schema that +does not exist, so each tab shows that agent's own shape - and an agent that +exposes nothing says so rather than showing a plausible zero. + + usage [-n SECONDS] + +Most of this is read from local state files. The exception is the remaining +quota, which no agent writes to disk in a current form: Claude, Codex, Cursor +and Copilot each publish one over an endpoint, fetched with the credential +that agent already holds and sent only to that agent's own host. Nothing here is +inferred from a number that was not published. + +Keys: left/right or tab switch agent, up/down scroll it, pgup/pgdn by +the page, home/end to either edge, r refreshes now, q quits. diff --git a/widgets/tests/check.rs b/widgets/tests/check.rs new file mode 100644 index 0000000..846995b --- /dev/null +++ b/widgets/tests/check.rs @@ -0,0 +1,940 @@ +// terminal-toys - small dependency-free terminal widgets +// Copyright (C) 2026 William Li +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! check.py, for the Rust side. +//! +//! Every check in check.py exists because something shipped broken, and +//! each is a fault that looks on screen exactly like "there is no data". +//! check.py reads only `*.py`, so none of them has ever run against these +//! fourteen binaries - and two of them fail today. +//! +//! These are tests rather than a fifteenth binary for two reasons: they +//! then run on every `cargo test` instead of waiting to be remembered, and +//! `start`'s menu asserts that every `[[bin]]` in the manifest is on it, so +//! a checker binary would have to be listed as a widget it is not. +//! +//! Two of check.py's five do not need porting and are recorded here rather +//! than silently dropped: +//! +//! - **unbound names**: a compile error in Rust. The fault it was written +//! for - deployments.py losing an import and its poll thread dying for a +//! day - cannot reach a built binary. +//! - **missing docs and README rows**: the docs are shared between the two +//! implementations and check.py already covers them. Re-checking here +//! would only duplicate it. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +/// The repo root, from this crate's own location. +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .canonicalize() + .expect("the repo root") +} + +/// Every selection tint a widget composes, and how it was reached. +/// +/// Returns `(tint, inline_colour)` - the colour named on the same line when +/// the tint is composed inline, and `None` when it is bound to a `tint` +/// variable and handed to a closure later. +/// +/// A whole statement at a time, not a line at a time. herdr-panes binds its +/// tint across nine lines: +/// +/// ```ignore +/// let tint = if here { +/// tc::bg(38, 56, 76) +/// } else if a.state == "blocked" { +/// ``` +/// +/// The line carrying that first `bg()` names no colour and does not contain +/// the word "tint", so a line-by-line scan saw neither and checked nothing +/// for it. It happened not to matter only because the same tint appears on +/// its own line twice more in the same file; alone, or changed on its own, an +/// AA failure there would have gone unseen. +fn tints_of(src: &str) -> Vec<((f64, f64, f64), Option<String>)> { + let mut found = Vec::new(); + let lines: Vec<&str> = src.lines().collect(); + let mut i = 0; + while i < lines.len() { + let line = lines[i]; + // A binding whose name is `tint`: take the statement to its end. + let binds_tint = line.contains("let tint") + || line.trim_start().starts_with("tint =") + || line.contains("let tint:"); + if binds_tint { + let mut depth: i32 = 0; + let mut j = i; + loop { + let l = lines[j]; + depth += l.matches('{').count() as i32 - l.matches('}').count() as i32; + if let Some(t) = triple(l, "tc::bg") { + found.push((t, None)); + } + // The statement ends at a `;` once every brace has closed. + if depth <= 0 && l.trim_end().ends_with(';') { + break; + } + j += 1; + if j >= lines.len() || j > i + 40 { + break; + } + } + i = j + 1; + continue; + } + // Otherwise a bg() composed inline, with the colour on the same line. + if let Some(t) = triple(line, "tc::bg") { + found.push((t, Some(line.to_string()))); + } + i += 1; + } + found +} + +/// Every `<name>_lit` the palette defines. +fn colours_ending_in_lit(src: &str) -> Vec<String> { + let mut found = Vec::new(); + for line in src.lines() { + let t = line.trim_start(); + if !t.contains(": tc::rgb(") { + continue; + } + if let Some(field) = t.split(':').next() { + if field.ends_with("_lit") && !field.contains(' ') { + found.push(field.to_string()); + } + } + } + found.sort(); + found.dedup(); + found +} + +/// Whether a palette colour is ever handed to the closure that composes a +/// selection tint. +/// +/// Most colours are drawn straight - `p.bad.as_str()` - and never meet a +/// tint; blaming those is how a contrast check starts crying wolf, which +/// this file has already had to fix twice. A colour counts if it is named on +/// a line that calls the tint helper, or if it is returned by one of the +/// small colour pickers whose result is then handed to it. +fn handed_to_a_tint(src: &str, field: &str) -> bool { + let named = format!("p.{}", field); + let mentions = |line: &str| match line.find(&named) { + // `p.dim` is a prefix of `p.dim_lit`. + Some(at) => !line[at + named.len()..].starts_with('_'), + None => false, + }; + src.lines().any(|line| { + let composes = line.contains("c(") || line.contains("c_of(") || line.contains("tinted("); + let picker = line.trim_start().starts_with("\"") && line.contains("=> &p."); + (composes && mentions(line)) || (picker && mentions(line)) + }) +} + +/// A source with its own test module removed. Fixtures are not the screen, +/// and a colour or a key that only appears in one is not shipped. +fn without_tests(src: &str) -> String { + src.split("#[cfg(test)]").next().unwrap_or("").to_string() +} + +/// Every widget binary, by stem, with its source. +fn widgets() -> BTreeMap<String, String> { + let dir = root().join("widgets/src/bin"); + let mut found = BTreeMap::new(); + for entry in std::fs::read_dir(&dir).expect("the bin directory").flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); + // Each file's own tests are dropped before joining, not after. Split + // on the first `#[cfg(test)]` of the joined blob and a widget with + // submodules is read only as far as its main file's tests - usage's + // are two thirds of the way down, so its eight submodules, seven + // thousand lines, were invisible to every check that did it that way. + let mut src = without_tests(&std::fs::read_to_string(&path).unwrap_or_default()); + // A widget split across a directory - usage - reads as one widget. + let sub = dir.join(&stem); + if sub.is_dir() { + for part in std::fs::read_dir(&sub).expect("a widget directory").flatten() { + if part.path().extension().and_then(|e| e.to_str()) == Some("rs") { + src.push('\n'); + src.push_str(&without_tests( + &std::fs::read_to_string(part.path()).unwrap_or_default(), + )); + } + } + } + found.insert(stem, src); + } + found +} + +/// Text inside double-quoted string literals, where hints live. +/// +/// Deliberately crude: it is looking for `[w]indow`, and a hint never +/// spans a line. Parsing Rust properly to find a footer would be a much +/// larger thing that failed in more interesting ways. +fn string_lines(src: &str) -> Vec<String> { + let mut out = Vec::new(); + for line in src.lines() { + let mut found = String::new(); + let mut rest = line; + while let Some(open) = rest.find('"') { + let after = &rest[open + 1..]; + match after.find('"') { + Some(close) => { + found.push_str(&after[..close]); + found.push(' '); + rest = &after[close + 1..]; + } + None => break, + } + } + if !found.trim().is_empty() { + out.push(found); + } + } + out +} + +/// The glyphs a hint uses instead of a name, and what they answer to. +/// +/// The control standard leans on these four, so a hint that names a key +/// only as an arrow or a return symbol is still a hint teaching a key. +const GLYPHS: &[(char, &str)] = &[ + ('\u{21b5}', "enter"), // ↵ + ('\u{2192}', "right"), // → + ('\u{2190}', "left"), // ← + ('\u{2191}', "up"), // ↑ + ('\u{2193}', "down"), // ↓ +]; + +/// Named keys that appear in prose: "esc, ↵ or i to close". +const NAMED: &[&str] = &["esc", "tab", "enter", "backspace", "pgup", "pgdn", "home", "end"]; + +/// The keys a hint teaches, in any of the forms this tree writes them. +/// +/// `[w]indow` and `[d] cloudflare` and `[±]1min` and `↵ starts one` and +/// `esc, ↵ or i to close` are all hints; `"[{}]"`, `"[::1]:"`, `"[[bin]]"` +/// and `"args[0]"` are all strings that merely contain brackets. The +/// separation is four rules, each of which exists for one of those: +/// +/// - exactly one character between the brackets - kills `[::1]` and `[[bin]]` +/// - that character is not `{` - kills the format placeholder `[{}]` +/// - the character before `[` is not alphanumeric - kills `args[0]` +/// - the character after `]` is not `.` or `(` - kills `][0].as_str()` +/// +/// An earlier version required a letter immediately after `]`, which threw +/// away every hint with a space in it. +fn hinted_keys(src: &str) -> BTreeSet<String> { + let mut found = BTreeSet::new(); + for text in string_lines(src) { + let chars: Vec<char> = text.chars().collect(); + // A footer separates its hints with `·` AND names at least one key + // unmistakably - in brackets, as a glyph, or by name. The + // separator alone is not enough: status lines use it too, and + // " {} targets · {:.1}s interval · " would otherwise offer `s` as + // a key, and "last {}d · " would offer `d`. + let names_a_key = text.contains('[') + || GLYPHS.iter().any(|(g, _)| text.contains(*g)) + || NAMED.iter().any(|n| { + text.to_lowercase() + .split(|c: char| !c.is_ascii_alphabetic()) + .any(|w| w == *n) + }); + let is_footer = text.contains('\u{b7}') && names_a_key; + for i in 0..chars.len() { + if chars[i] == '[' && i + 2 < chars.len() && chars[i + 2] == ']' { + let key = chars[i + 1]; + let before_ok = i == 0 || !chars[i - 1].is_ascii_alphanumeric(); + let after_ok = chars + .get(i + 3) + .is_none_or(|c| *c != '.' && *c != '(' && *c != '['); + if key != '{' && before_ok && after_ok { + match GLYPHS.iter().find(|(g, _)| *g == key) { + // [↵] means the same as a bare ↵. + Some((_, name)) => { + found.insert((*name).to_string()); + } + // [±] is one glyph standing for a pair of arms, + // "+" | "=" and "-" | "_". clocks writes it that + // way because the footer has room for one hint and + // the widget has two keys. + None if key == '\u{b1}' => { + found.insert("+".into()); + found.insert("-".into()); + } + None => { + found.insert(key.to_lowercase().to_string()); + } + } + } + } + if is_footer { + if let Some((_, name)) = GLYPHS.iter().find(|(g, _)| *g == chars[i]) { + found.insert((*name).to_string()); + } + } + } + // A key named in prose - "esc, ↵ or i to close" - counts only in a + // footer. This is the form that matters most: it is where the + // control standard puts its hints, and a key removed while its + // prose hint stayed is exactly what this check is for. + if is_footer { + let lowered = text.to_lowercase(); + for word in lowered.split(|c: char| !c.is_ascii_alphanumeric()) { + if NAMED.contains(&word) || word.chars().count() == 1 { + found.insert(word.to_string()); + } + } + } + } + found.remove(""); + found +} + +/// The keys a match arm answers to, single characters and named alike. +/// +/// Case-insensitive on purpose: arms read `"q" | "Q"`, and a pattern that +/// only matched lowercase would fail the whole alternation on the +/// uppercase half - which is exactly the bug that made an earlier hand-run +/// of this rule report 48 failures against widgets that were all correct. +fn handled_keys(src: &str) -> BTreeSet<String> { + let mut found = BTreeSet::new(); + for line in src.lines() { + if line.trim_start().starts_with("//") { + continue; + } + // A match arm, up to its =>; or a comparison anywhere on the line. + // ports answers `f` with `if key == "f"` and `y` with + // `if key != "y"`, and neither is an arm. + let head = match line.find("=>") { + Some(at) => &line[..at], + None if line.contains("key ==") || line.contains("key !=") => line, + None => continue, + }; + if !head.contains('"') { + continue; + } + let mut rest = head; + while let Some(open) = rest.find('"') { + let after = &rest[open + 1..]; + let Some(close) = after.find('"') else { break }; + let word = &after[..close]; + // Any single character counts, punctuation included: "?" and + // "/" are real keys. Longer words count when they name one. + let single = word.chars().count() == 1; + if single || word.chars().all(|c| c.is_ascii_alphabetic()) { + found.insert(word.to_lowercase()); + } + rest = &after[close + 1..]; + } + } + // A guard rather than a literal: `digit if digit.chars().all(is_ascii_digit)` + // answers every digit and leaves no "1" to find. + if src.contains("is_ascii_digit()") && src.contains(" if ") { + for d in '0'..='9' { + found.insert(d.to_string()); + } + } + found +} + +/// The config section a widget declares, and the keys it reads from it. +/// Rejoin a wrapped method chain onto the line it belongs to. +/// +/// `cfg.get("show_hints")` and the same call split over two lines are one +/// read, and rustfmt picks between them on line width alone - so a scanner +/// working a line at a time sees the first and is blind to the second. +/// Two real settings, clocks' `show_hints` and `work_days`, sat +/// undocumented behind exactly that shape while this check reported all +/// clear. A blind spot in a check that exists to find undocumented +/// settings is worse than no check, because it is read as proof. +/// +/// Only a newline whose next non-space character is `.` is removed, which +/// is narrow enough that nothing else on either line changes meaning. +/// Widening the receiver instead - accepting `raw.get(` and `gh.get(` as +/// well as `cfg.get(` - was tried and reverted: it picks up `cwnd` and +/// `reord_seen`, which are fields link reads out of `ss` output and not +/// config at all. +fn join_chains(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + for line in src.lines() { + if line.trim_start().starts_with('.') && !out.is_empty() { + out.push_str(line.trim_start()); + } else { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line); + } + } + out +} + +fn config_use(src: &str) -> (BTreeSet<String>, BTreeSet<String>) { + let mut sections = BTreeSet::new(); + let mut keys = BTreeSet::new(); + let joined = join_chains(src); + for line in joined.lines() { + if let Some(at) = line.find("load_config(\"") { + let after = &line[at + 13..]; + if let Some(end) = after.find('"') { + sections.insert(after[..end].to_string()); + } + } + // cfg_f64(&cfg, "key", ...), cfg_str(cfg, "key", ...), and the + // direct cfg.get("key") that several widgets use for bools. + // + // The receiver matters: a bare `.get("` also matches every JSON + // lookup in the file - clocks reading its own state file, link + // parsing `ss` output, usage reading token counts - none of which + // is config. That produced 24 false failures on the first run. + for marker in ["cfg_f64(", "cfg_usize(", "cfg_str(", "cfg_strings(", "cfg.get("] { + let mut from = 0; + while let Some(at) = line[from..].find(marker) { + let start = from + at + marker.len(); + if let Some(open) = line[start..].find('"') { + let after = &line[start + open + 1..]; + if let Some(end) = after.find('"') { + let key = &after[..end]; + if !key.is_empty() + && key.chars().all(|c| c.is_ascii_lowercase() || c == '_') + { + keys.insert(key.to_string()); + } + } + } + from = start; + } + } + } + (sections, keys) +} + +/// The example config, as section -> keys. +fn example() -> BTreeMap<String, BTreeSet<String>> { + let text = std::fs::read_to_string(root().join("config.example.json")) + .expect("config.example.json"); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid json"); + let mut out = BTreeMap::new(); + for (section, body) in parsed.as_object().expect("an object") { + if section.starts_with('_') { + continue; + } + let keys = body + .as_object() + .map(|o| o.keys().filter(|k| !k.starts_with('_')).cloned().collect()) + .unwrap_or_default(); + out.insert(section.clone(), keys); + } + out +} + +#[test] +fn every_footer_hint_names_a_key_the_widget_answers_to() { + // A hint bound to nothing is worse than a missing feature: it says the + // feature is there. This is the check that caught four shell ports. + let mut wrong = Vec::new(); + for (name, src) in widgets() { + let handled = handled_keys(&src); + for key in hinted_keys(&src) { + if !handled.contains(&key) { + wrong.push(format!("{}: [{}] is hinted, no match arm answers it", name, key)); + } + } + } + assert!(wrong.is_empty(), "\n{}", wrong.join("\n")); +} + +#[test] +fn every_footer_hint_is_in_the_widgets_doc() { + // The other direction of the same rule: a documented key that does not + // exist teaches a lie, and so does an undocumented one that works. + let mut wrong = Vec::new(); + for (name, src) in widgets() { + let doc = root().join("docs").join(format!("{}.md", name)); + let Ok(text) = std::fs::read_to_string(&doc) else { + continue; // matrix is decorative and deliberately undocumented + }; + for key in hinted_keys(&src) { + // The docs write these as the glyph, the footers as the name: + // a table row reads `↵` where the code answers to "enter". + // Either spelling documents the key. + let glyph = GLYPHS + .iter() + .find(|(_, n)| *n == key) + .map(|(g, _)| format!("`{}`", g)); + let documented = text.contains(&format!("`{}`", key)) + || glyph.is_some_and(|g| text.contains(&g)); + if !documented { + wrong.push(format!("{}: [{}] in the footer, not in docs/{}.md", name, key, name)); + } + } + } + assert!(wrong.is_empty(), "\n{}", wrong.join("\n")); +} + +#[test] +fn every_key_a_widget_reads_is_in_the_example() { + // The direction check.py does NOT check, in either language. All + // eleven pomodoro_* keys were read by clocks.py and absent from + // config.example.json since before the port, so breaks were + // configurable the whole time and undiscoverable. CLAUDE.md asks for + // new keys to be added in the same commit; nothing was watching. + let example = example(); + let mut wrong = Vec::new(); + for (name, src) in widgets() { + let (sections, keys) = config_use(&src); + for key in keys { + let known = sections + .iter() + .any(|s| example.get(s).is_some_and(|ks| ks.contains(&key))); + if !known && !sections.is_empty() { + wrong.push(format!( + "{}: reads {:?} from {:?}, which config.example.json does not document", + name, key, sections + )); + } + } + } + assert!(wrong.is_empty(), "\n{}", wrong.join("\n")); +} + +#[test] +fn every_section_in_the_example_is_read_by_the_widget_it_names() { + // check.py's dead-key check, pointed at the Rust. A section nobody + // reads is a lie in a sample file: it invites someone to set something + // and watch nothing happen. + let example = example(); + let widgets = widgets(); + let mut wrong = Vec::new(); + for section in example.keys() { + // Sections are named for widgets, with _ where the file has -. + let stem = section.replace('_', "-"); + let Some(src) = widgets.get(section).or_else(|| widgets.get(&stem)) else { + continue; // a section for something that is not a Rust widget + }; + let (declared, _) = config_use(src); + if !declared.contains(section) { + wrong.push(format!( + "config.example.json has a {:?} section and {}.rs never calls load_config for it", + section, stem + )); + } + } + assert!(wrong.is_empty(), "\n{}", wrong.join("\n")); +} + +#[test] +fn every_config_read_falls_back_to_a_code_default() { + // A key deleted from config.json must land on the widget's own + // default, not on zero and not on a panic. cfg_f64 and its siblings + // take a fallback by signature; a bare cfg.get() does not, so those + // are the ones that can go wrong. + // + // Checked live as well as here: clocks with no config file, with no + // clocks section, with an empty section, and with only the focus key + // set, all show the code's own durations. + let mut wrong = Vec::new(); + for (name, src) in widgets() { + let mut from = 0; + while let Some(at) = src[from..].find(".get(\"") { + let start = from + at; + from = start + 5; + // Only reads of the config value itself; every other .get() in + // these files is a JSON lookup on something else. + let before = &src[start.saturating_sub(40)..start]; + if !before.trim_end().ends_with("cfg") && !before.trim_end().ends_with("&cfg") { + continue; + } + let key: String = src[from..] + .chars() + .take_while(|c| *c != '"') + .collect(); + // The statement this read belongs to, not a fixed window: an + // earlier hand-audit used 260 characters and wrongly flagged + // work_days, whose fallback is simply further along. + let rest = &src[start..]; + let stop = rest.find(';').unwrap_or(rest.len()); + let statement = &rest[..stop]; + let guarded = statement.contains("unwrap_or") + || statement.contains("unwrap_or_else") + || statement.contains("unwrap_or_default") + // Asking whether a key is *there* has no value to default. + // clocks needs it: a `cities` list that was set and came to + // nothing must stay nothing, where an absent one gets the + // code's four. Collapsing those two is the bug, and the + // fallback this rule asks for is what would collapse them. + || statement.contains(".is_some()") + || statement.contains(".is_none()"); + if !guarded { + wrong.push(format!( + "{}: reads {:?} from config with no fallback in the statement", + name, key + )); + } + } + } + assert!(wrong.is_empty(), "\n{}", wrong.join("\n")); +} + +#[test] +fn every_key_in_the_example_is_read_by_the_widget_it_belongs_to() { + // check.py's rule, per key rather than per section: a key in the + // example that no widget reads is a lie in a sample file. Checking + // only that the section is loaded misses the case where a widget + // reads three of its four keys and ignores the fourth. + let example = example(); + let widgets = widgets(); + let mut wrong = Vec::new(); + for (section, keys) in &example { + let stem = section.replace('_', "-"); + let Some(src) = widgets.get(section).or_else(|| widgets.get(&stem)) else { + continue; // a section for something that is not a Rust widget + }; + for key in keys { + // Any mention of the key as a string literal counts. The point + // is whether the widget knows the name at all, not which + // helper it reaches for. + if !src.contains(&format!("\"{}\"", key)) { + wrong.push(format!( + "config.example.json documents {}.{} and {}.rs never reads it", + section, key, stem + )); + } + } + } + assert!(wrong.is_empty(), "\n{}", wrong.join("\n")); +} + +#[test] +fn every_key_the_help_text_names_is_answered() { + // --help is where someone looks when the footer is not enough, so a + // key named there and not implemented is the same lie as a footer + // hint bound to nothing. Nothing was reading these files. + // + // The help is prose, so there are no brackets to key off. Only two + // shapes count, both hard to write by accident: a letter right after + // "press", and a letter right before a verb - "c opens a detail + // view". Reading every single letter instead took "a" from "with a + // longer" and reported a key called a. + // + // Known limitation, stated rather than hidden: in "Enter, i or c + // opens", only `c` touches the verb, so a stale `i` beside it is + // missed. Catching one of the two still lands the reader in the right + // sentence. + const VERBS: &[&str] = &[ + "opens", "cycles", "toggles", "quits", "refreshes", "closes", "copies", + ]; + let dir = root().join("widgets/src/bin"); + let mut wrong = Vec::new(); + for (name, src) in widgets() { + let help = dir.join(format!("{}_help.txt", name)); + let Ok(text) = std::fs::read_to_string(&help) else { + continue; + }; + let handled = handled_keys(&src); + for line in text.lines() { + let lower = line.to_lowercase(); + let words: Vec<&str> = lower + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|w| !w.is_empty()) + .collect(); + for (i, word) in words.iter().enumerate() { + if word.chars().count() != 1 { + continue; + } + let after_press = i > 0 && words[i - 1] == "press"; + let before_verb = words + .get(i + 1) + .is_some_and(|next| VERBS.contains(next)); + if (after_press || before_verb) && !handled.contains(*word) { + wrong.push(format!( + "{}_help.txt names {:?} and {}.rs does not answer it", + name, word, name + )); + } + } + } + } + wrong.sort(); + wrong.dedup(); + assert!(wrong.is_empty(), "\n{}", wrong.join("\n")); +} + +fn luminance(rgb: (f64, f64, f64)) -> f64 { + let ch = |c: f64| { + let c = c / 255.0; + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(rgb.0) + 0.7152 * ch(rgb.1) + 0.0722 * ch(rgb.2) +} + +fn contrast(a: (f64, f64, f64), b: (f64, f64, f64)) -> f64 { + let (x, y) = (luminance(a), luminance(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) +} + +/// The three numbers in the first `call(r, g, b)` on a line. +fn triple(line: &str, call: &str) -> Option<(f64, f64, f64)> { + let at = line.find(call)?; + let open = line[at..].find('(')? + at; + let close = line[open..].find(')')? + open; + let n: Vec<f64> = line[open + 1..close] + .split(',') + .filter_map(|x| x.trim().parse().ok()) + .collect(); + (n.len() == 3).then(|| (n[0], n[1], n[2])) +} + +/// Text drawn over a selection tint has to clear AA against the tint, not +/// only against the terminal's own background. +/// +/// CLAUDE.md has asked for this in prose since before the port, and it went +/// unmet in four widgets for as long as there were four widgets: `dim` at +/// (127, 147, 172) measures 3.81 on `bg(38, 56, 76)`. The count of places it +/// reached a tinted row grew from seventeen to twenty-three while that sat +/// open, which is what prose costs. +/// +/// It checks **every** palette colour a widget defines, not the one that was +/// measured first. The version before this one looked only at `dim` and +/// `dim_lit`, and a review found three more failing on the same tint through +/// the same closures - `bad` at 4.17, `unknown` at 4.11, and `idle` at 3.85, +/// which is what most of herdr-panes' rows are. A check that only knows +/// about the bug it was written for finds that bug and stops. +/// +/// A colour with a `_lit` twin is exempt: the twin is what reaches the tint, +/// and it is measured instead. Only colours that meet a tint are compared - +/// an earlier version paired every `bg()` in a file with every colour in it +/// and reported two widgets that were fine, one on a tint that exists only +/// in a test fixture. A check that cries wolf gets turned off. +#[test] +fn text_on_a_selection_tint_clears_aa() { + let mut wrong = Vec::new(); + for (name, src) in widgets() { + // Every colour the palette defines, by field. + let mut colours: BTreeMap<String, (f64, f64, f64)> = BTreeMap::new(); + for line in src.lines() { + let t = line.trim_start(); + let Some(field) = t.split(':').next() else { continue }; + if !t.contains(": tc::rgb(") || field.contains(' ') || field.is_empty() { + continue; + } + if let Some(c) = triple(line, "tc::rgb") { + colours.insert(field.to_string(), c); + } + } + for (tint, inline_line) in tints_of(&src) { + // Composed inline with a named colour: that exact pair. Bound to + // `tint` and handed to a closure: every colour the closure is + // given can land on it. + let inline: Vec<String> = match inline_line.as_ref() { + None => Vec::new(), + Some(line) => colours + .keys() + .filter(|f| { + // `p.dim` is a prefix of `p.dim_lit`; without the + // guard a line drawing only the lighter one would be + // blamed for the darker one it never draws. + let needle = format!("p.{}", f); + match line.find(&needle) { + None => false, + Some(at) => !line[at + needle.len()..].starts_with('_'), + } + }) + .cloned() + .collect(), + }; + let reached: Vec<String> = if !inline.is_empty() { + inline + } else if inline_line.is_none() { + // Not every colour in the palette reaches the tint - most are + // drawn straight, as `p.bad.as_str()`. Only the ones handed + // to a closure count, directly or through a colour picker + // whose arms return them. Without this the check reported + // github's `bad`, which is only ever drawn on the plain + // background, and a check that cries wolf gets turned off. + colours + .keys() + .filter(|f| handed_to_a_tint(&src, f)) + .cloned() + .collect() + } else { + continue; + }; + for field in reached { + // Gridlines are not text, and say so where they are defined. + if field.contains("grid") { + continue; + } + // The lighter twin is what reaches the tint, so the twin is + // what gets measured. Skipping both - which this did briefly, + // and which passed a mutation that put a failing value back + // into a `_lit` field - measures nothing at all. + let field = match colours.contains_key(&format!("{}_lit", field)) { + true => format!("{}_lit", field), + false => field, + }; + let Some(&c) = colours.get(&field) else { continue }; + let r = contrast(c, tint); + if r < 4.5 { + wrong.push(format!( + "{}: {} {:?} on tint {:?} measures {:.2}, under AA 4.5", + name, field, c, tint, r + )); + } + } + } + } + wrong.sort(); + wrong.dedup(); + assert!(wrong.is_empty(), "on the selected-row tint:\n{}", wrong.join("\n")); +} + +/// A widget with a lighter grey has to actually reach for it. +/// +/// The check above measures the colour and cannot see the wiring: delete the +/// substitution inside the tint closure and `dim` goes back on the tint while +/// `dim_lit` sits in the palette measuring beautifully. So the substitution +/// is counted instead - once per closure that composes a tint. +#[test] +fn a_widget_with_a_lighter_grey_uses_it_on_every_tint() { + let mut wrong = Vec::new(); + for (name, whole) in widgets() { + let src = whole.split("#[cfg(test)]").next().unwrap_or("").to_string(); + if !src.contains("dim_lit: tc::rgb") { + continue; + } + let closures = src.matches("format!(\"{}{}\", tint, colour)").count(); + // Every lighter colour the palette defines has to be reached for by + // every closure that composes a tint. Counting only `dim_lit` let a + // mutation through: unwiring the `idle_c` arm left the dim_lit count + // untouched and the check green, which is the shape of hole this + // test exists to close. + // A swap and the guard that reaches it come in pairs. Counting + // occurrences alone cannot see a guard that has been neutered - the + // body still mentions the lighter colour, so the count is unchanged + // and the check stays green while the swap never fires. + for lit in colours_ending_in_lit(&src) { + let base = lit.trim_end_matches("_lit"); + let swaps = src.matches(&format!("p.{}.as_str()", lit)).count(); + let guards = src.matches(&format!("colour == p.{} {{", base)).count(); + if swaps != guards { + wrong.push(format!( + "{}: {} reached {} times but guarded on `colour == p.{}` {} times", + name, lit, swaps, base, guards + )); + } + } + for lit in colours_ending_in_lit(&src) { + let swaps = src.matches(&format!("p.{}.as_str()", lit)).count() + + src.matches(&format!("&p.{}", lit)).count(); + if swaps < closures { + wrong.push(format!( + "{}: {} tint closures but {} reach for {}", + name, closures, swaps, lit + )); + } + } + } + assert!(wrong.is_empty(), "a lighter grey nobody draws:\n{}", wrong.join("\n")); +} + +#[test] +fn a_poller_that_dies_records_why() { + // CLAUDE.md's central gotcha: a thread that stops takes its + // explanation with it, and an empty pane is indistinguishable from a + // source with nothing in it. Any widget that spawns a thread must have + // somewhere to put the reason. + let mut wrong = Vec::new(); + for (name, src) in widgets() { + if !src.contains("thread::spawn") { + continue; + } + // Two accidents used to satisfy this, and both were found by reading + // the widgets rather than by the check failing. + // + // `catch_unwind` on its own counted as recording a reason. It is + // not: ports caught its panic and threw the reason away with + // `unwrap_or_default()`, drawing an empty table - the exact thing + // this rule exists to prevent, under a comment saying so. + // + // And a bare `err =` matched `let mut err = dx + dy`, the Bresenham + // variable in latency's line drawing. That one accident was the + // whole reason latency passed. + // + // So a reason has to look like a reason - assigned something with + // words in it - and it has to reach a row. Recording one nobody + // draws is the same silence with more code behind it. + let reason = |line: &str| { + // A field called `err` holds a reason whatever it is assigned + // from - netwatch's `state.err = err` hands over a String built + // further up. A bare local is where the Bresenham variables + // live, so that one has to be assigned something with words in + // it. + if line.contains(".err =") || line.contains(".why =") { + return true; + } + let Some(at) = line.find("err =").or_else(|| line.find("why =")) else { + return false; + }; + let rhs = &line[at..]; + rhs.contains('"') || rhs.contains("format!") || rhs.contains("to_string") + }; + let records = src + .lines() + .any(|l| !l.trim_start().starts_with("//") && reason(l)); + // On screen, not on stderr: netwatch writes one to stderr *and* + // draws it, and only the drawn one is any use behind a full-screen + // redraw. + let drawn = src.contains("err.is_empty()") + || src.contains("&err") + || src.contains("why.is_empty()"); + // One shape is always wrong, whatever else the widget records: a + // caught panic whose reason goes straight to `unwrap_or_default()`. + // That returns an empty list and the pane draws as if the source had + // nothing in it. Checked on its own, because "the widget records a + // reason somewhere" is true of a widget with one good guard and one + // bad one - which is exactly what ports was. + for (n, line) in src.lines().enumerate() { + if line.contains("catch_unwind") && line.contains("unwrap_or_default") { + wrong.push(format!( + "{}:{}: catches a panic and throws the reason away", + name, + n + 1 + )); + } + } + if !records { + wrong.push(format!( + "{}: spawns a poll thread with nowhere to record why it stopped", + name + )); + } else if !drawn { + wrong.push(format!( + "{}: records why its poller stopped and never puts it on screen", + name + )); + } + } + assert!(wrong.is_empty(), "\n{}", wrong.join("\n")); +}