From 06ea8b789e608604a7754dc5fac24cdb8b012e83 Mon Sep 17 00:00:00 2001 From: TGPSKI Date: Tue, 4 Aug 2026 22:44:11 -0700 Subject: [PATCH] Vendor the TUI layer from pane, its new canonical upstream assets/tui/ is now vendored from github.com/TGPSKI/pane (tools/vendor.sh, pane@a51e682): charts.py gains aggregation binning, outlier clipping and top-row label collision handling; interact.py joins the set (prompt_search, filter_rows, hbar, cycle); __init__.py carries the provenance stamp. Docs updated to point at pane: assets/tui/README.md rewritten, LINEAGE.md gains generation 0b' and re-dates the byte-identity claim, phase-03-browser vendoring guidance, PATTERN.md, CHANGELOG. --- CHANGELOG.md | 11 ++ LINEAGE.md | 33 ++++-- PATTERN.md | 8 +- assets/tui/README.md | 55 +++++++--- assets/tui/__init__.py | 9 ++ assets/tui/charts.py | 195 ++++++++++++++++++++++++++++----- assets/tui/interact.py | 69 ++++++++++++ references/phase-03-browser.md | 14 ++- 8 files changed, 329 insertions(+), 65 deletions(-) create mode 100644 assets/tui/interact.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1c591..aba709e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `assets/tui/` is now vendored from its canonical public upstream, + [pane](https://github.com/TGPSKI/pane), and picks up the 2026-08-04 + `bar_chart` upgrades (aggregation binning, outlier clipping, top-row + label collision handling) plus the new `interact.py` module + (`prompt_search`, `filter_rows`, `hbar`, `cycle`). Byte-identity is now + checkable with `make vendor-check` in a pane checkout; provenance + updated in `assets/tui/README.md`, `LINEAGE.md`, + `references/phase-03-browser.md`, and `PATTERN.md`. + ## [0.1.0] - 2026-08-04 ### Added diff --git a/LINEAGE.md b/LINEAGE.md index 0a1b619..d5c4c56 100644 --- a/LINEAGE.md +++ b/LINEAGE.md @@ -20,6 +20,7 @@ Everything below is checkable. Dates come from `git log`. | 2026-07-30 | **2** — the re-application | `15-trust-repair/eval/watch-matrix.sh`, 128 lines | Proof the pattern ports as rules, not code | | 2026-08-03 | — | the `no-out` reattribution | L17 — the screen produces suspicion, not evidence | | 2026-08-03/04 | **3** — the live view | `matrix_tui.py` (1,869) + `live.py` (629) | Work in flight; probed liveness; process control | +| 2026-08-04 | **0b′** — the framework goes public | [pane](https://github.com/TGPSKI/pane) | Generation 0b promoted to its own repository: the canonical upstream every copy now re-vendors from, with the byte-identity check as tooling (`make vendor-check`) instead of an instruction | Nothing here took a quarter to mature, and the pattern did not begin in an eval. It began in operations. @@ -31,16 +32,20 @@ over live server data: `sh-web-analytics` (nginx access logs across several domains) and `sh-github-analytics` (repository traffic). Both predate the eval suite by more than two weeks. -Both currently live in a private operations repository. `sh-github-analytics` -is slated for public release as **`catnip`**; this document will carry the link -once it lands rather than pointing at a URL that does not resolve yet. -`sh-web-analytics` reads production access logs and is expected to stay -private. - -So for now, unlike every other reference in this document, generation 0 cannot -be followed. The claims made about it below are deliberately the ones that -survive that: line counts, dates, a docstring, and a byte-for-byte diff against -code you *can* read in [leather](https://github.com/TGPSKI/leather) and +Both apps currently live in a private operations repository. +`sh-github-analytics` is slated for public release as **`catnip`**; this +document will carry the link once it lands rather than pointing at a URL that +does not resolve yet. `sh-web-analytics` reads production access logs and is +expected to stay private. + +The *framework layer* they share, however, is public as of 2026-08-04: +[**pane**](https://github.com/TGPSKI/pane) is the canonical upstream of +`shared/tui/`, carrying the provenance hashes of the extraction in its own +LINEAGE.md. The apps — the readers, views, and callouts — are what remain +private; the claims made about them below are deliberately the ones that +survive that: line counts, dates, a docstring, and a byte-for-byte diff +against code you *can* read in [pane](https://github.com/TGPSKI/pane), +[leather](https://github.com/TGPSKI/leather) and [adherence-suite](https://github.com/TGPSKI/adherence-suite). They matter for three reasons. @@ -52,7 +57,7 @@ those two apps on 2026-07-13 — its own docstring says so: > bounds-checked put, base color pairs, run loop with min-size guard, footer > renderer, scroll indicator, CSV loading, and the curses.wrapper bootstrap. -All four files are **byte-identical** to the `tui/` package vendored into +All four files were **byte-identical** to the `tui/` package vendored into leather's eval scripts and again into adherence-suite: ``` @@ -62,6 +67,12 @@ framework.py 108 lines IDENTICAL windows.py 58 lines IDENTICAL ``` +(Since 2026-08-04 the upstream is [pane](https://github.com/TGPSKI/pane), +`charts.py` has grown aggregation binning and outlier clipping, a fifth +module `interact.py` joined at its third duplication, and the identity +claim is a command — `make vendor-check` in pane — rather than a table +that can go stale like this one did.) + So **L8's `N–M of T` indicator is not an eval idea.** It is `TuiApp.scroll_indicator`, written for a log dashboard sixteen days earlier, and every later watcher inherited it without rewriting a line. diff --git a/PATTERN.md b/PATTERN.md index 56229da..0a1a12d 100644 --- a/PATTERN.md +++ b/PATTERN.md @@ -224,10 +224,10 @@ What transfers: rank by the question, name your callouts after what they already ruined, distinct glyphs for distinct absences, probe liveness, one data path. -**The exception is the drawing layer.** `shared/tui/` — bounds-checked put, a -min-size guard, a footer renderer, a scroll indicator — has been vendored -byte-identically into three codebases across twenty-five days, because it -encodes nothing about any particular job. That is the test for what belongs in +**The exception is the drawing layer.** [pane](https://github.com/TGPSKI/pane) +— bounds-checked put, a min-size guard, a footer renderer, a scroll indicator +— has been vendored byte-identically into four codebases across twenty-five +days, because it encodes nothing about any particular job. That is the test for what belongs in a framework: if it knows what the numbers *mean*, it is not framework, and copying it is how a watcher inherits the wrong question. diff --git a/assets/tui/README.md b/assets/tui/README.md index d60655a..da94bd8 100644 --- a/assets/tui/README.md +++ b/assets/tui/README.md @@ -1,7 +1,9 @@ -# `assets/tui/` — the drawing layer, to be copied verbatim +# `assets/tui/` — the drawing layer, vendored from pane -Three stdlib-only modules, 326 lines. **Copy them into the target repository in -Phase 3. Do not rewrite them, and do not improve them.** +Five stdlib-only modules, ~570 lines, vendored byte-identically from +[**pane**](https://github.com/TGPSKI/pane) — the canonical upstream of this +package. **Copy them into the target repository in Phase 3. Do not rewrite +them, and do not improve them.** This is the one part of a run watcher that ports as *code* rather than as rules, and the reason is the test worth remembering: @@ -10,33 +12,45 @@ rules, and the reason is the test worth remembering: Nothing in here knows what a trial, an arm, a request or a repository is. It can put a string inside the terminal bounds, refuse to draw in a window too -small to be honest, render a bar, and say `1-22/73`. That is all — which is -exactly why it has survived three codebases unchanged while every layer above -it was rewritten from scratch each time. +small to be honest, render a bar, prompt for a search, and say `1-22/73`. +That is all — which is exactly why it has survived four codebases unchanged +while every layer above it was rewritten from scratch each time. ## Provenance -Extracted on 2026-07-13 from two live server-analytics dashboards, and vendored -byte-identically since. The files here match, to the byte, the copies in: +Extracted on 2026-07-13 from two live server-analytics dashboards, vendored +byte-identically since, and promoted to its own repository — +[pane](https://github.com/TGPSKI/pane) — on 2026-08-04. The files here match, +to the byte, `src/pane/` upstream and the copies in: - [leather](https://github.com/TGPSKI/leather) — `examples/14-sig-triage/eval/scripts/tui/` - [adherence-suite](https://github.com/TGPSKI/adherence-suite) — `src/adherence/tui/` -Verify it yourself rather than believing it: +Verify it yourself rather than believing it — pane ships the checker: ```bash -diff assets/tui/framework.py /path/to/leather/examples/14-sig-triage/eval/scripts/tui/framework.py +cd pane && make vendor-check # diffs every portfolio copy against src/pane ``` -See [LINEAGE.md](../../LINEAGE.md) for why that matters. +To refresh this copy after an upstream change: + +```bash +cd pane && tools/vendor.sh ../run-watcher/assets/tui +``` + +See [LINEAGE.md](../../LINEAGE.md) here and +[pane's LINEAGE.md](https://github.com/TGPSKI/pane/blob/main/LINEAGE.md) for +why the byte-identity claim matters. ## What each module gives you | File | Provides | Laws it carries | |---|---|---| | `framework.py` | `TuiApp` (bounds-checked `_put`, colour pairs, run loop, footer, **`scroll_indicator`**), `curses_main` | **L8** — the `N–M of T` indicator is `scroll_indicator`, not something you write per project | -| `charts.py` | `bar_chart` and friends | — | +| `charts.py` | `bar_chart`: single or stacked series, peak markers, half-blocks, aggregation binning (long series bin rather than fall off the right edge), outlier clipping (`clip_ratio` caps the y-axis at a robust bound and labels what ran past it) | **L5**'s spirit at the axis: the y-max must describe a bar the user can actually see | | `fmt.py` | `compact_num`, `human_bytes`, `duration`, `pct`, `sparkline`, safe `to_int`/`to_float` | **L5** — the coercers return a default rather than raising on a torn value | +| `interact.py` | `prompt_search` (bottom-row `/`), `filter_rows`, `hbar`, `cycle` | Extracted at the third duplication; takes the app as an argument so `framework.py` stays byte-identical | +| `windows.py` | `trailing_hours`, `trailing_days`, `trend`, `stack_cells` | see below | Two things in `framework.py` are load-bearing and easy to delete by accident: @@ -48,7 +62,7 @@ Two things in `framework.py` are load-bearing and easy to delete by accident: ## `windows.py`, and why it ships anyway -All four modules are here, including `windows.py`, which looks at first like +All five modules are here, including `windows.py`, which looks at first like pure analytics vestige — `trailing_hours`, `trailing_days` and `trend` are time-series helpers whose docstring names the "hourly CSV wire format", and no watcher in three generations has called any of them. @@ -64,10 +78,8 @@ if segments: time-series module. Dropping `windows.py` leaves `bar_chart` working for every call the existing watchers make, and raising `ImportError` the first time someone passes stacked segments — a latent failure planted in the one component -that is supposed to be the safe part. - -Keeping the package whole also keeps it byte-identical to the public copies, -which is what makes the `diff` above worth running. +that is supposed to be the safe part. This is why pane's `tools/vendor.sh` +copies the whole package or nothing. **Genuinely unused, and safe to ignore:** `read_csv` in `framework.py`, and the three time-series functions above. They survive for byte-identity, not because @@ -77,6 +89,13 @@ The lesson generalizes past this file: **"unused" and "unreachable" are not the same claim.** A lazy import inside a branch is invisible to every check that reads the top of a file. +## Testing what you build on it + +pane also ships `pty_smoke.py` (not vendored here — it's test infra, and +POSIX-only): drive any TUI in a real pseudo-terminal, feed it keys, assert a +clean exit with no traceback. Point it at your watcher from your own test +suite rather than writing a second harness. + ## What you still have to write Everything that knows anything: @@ -87,5 +106,5 @@ Everything that knows anything: - every law from the [design laws](../../references/design-laws.md) except L8's indicator, which you get for free -The framework is roughly 300 lines. A useful watcher is 300–2,500 more. Copying +The framework is roughly 570 lines. A useful watcher is 300–2,500 more. Copying this is the last shortcut you get. diff --git a/assets/tui/__init__.py b/assets/tui/__init__.py index e69de29..8cc9897 100644 --- a/assets/tui/__init__.py +++ b/assets/tui/__init__.py @@ -0,0 +1,9 @@ +"""Vendored curses-TUI primitives (stdlib only). + +Source: https://github.com/TGPSKI/pane @ a51e682 +(src/pane: framework.py, charts.py, fmt.py, windows.py, interact.py), +copied verbatim so this repository runs from a fresh clone with no +external path dependency. Upstream owns the API; keep edits there and +re-vendor with pane's tools/vendor.sh rather than diverging here. +Verify with pane's tools/check-vendor.sh. +""" diff --git a/assets/tui/charts.py b/assets/tui/charts.py index 2fc97db..3003669 100644 --- a/assets/tui/charts.py +++ b/assets/tui/charts.py @@ -18,34 +18,67 @@ from .fmt import compact_num +def _bin_series(series, k): + """Aggregate every k adjacent buckets into one (counts sum).""" + out = [] + for i in range(0, len(series), k): + chunk = series[i:i + k] + item = {"label": chunk[0]["label"], + "count": sum(b["count"] for b in chunk)} + if any(b.get("peak") for b in chunk): + item["peak"] = True + segs = [b["segments"] for b in chunk if b.get("segments")] + if segs: + item["segments"] = [ + (sum(s[j][0] for s in segs if j < len(s)), + next(s[j][1] for s in segs if j < len(s))) + for j in range(max(len(s) for s in segs)) + ] + out.append(item) + return out + + def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, title=None, title_attr=0, axis_attr=0, color=0, fmt=compact_num, right_margin=2, bar_w=None, pref_bar_w=None, max_bar_w=6, overflow="shrink", value_labels=False, peak_attr=None, label_every=1, label_row_offset=1, label_pad=2, - half_blocks=False, label_fit=False, + half_blocks=False, label_fit=False, bin_unit="", + clip_ratio=None, clip_min_bars=5, clip_max_frac=0.25, no_data_text="no data available"): """Draw a vertical bar chart of series from row `top`; return next row. put(y, x, text, attr) is the caller's bounds-checked writer. - overflow: 'shrink' recomputes bar width to fit all bars (github style); + overflow: 'shrink' fits all bars — narrowing them, then aggregating + adjacent buckets once even 1-column bars won't fit (github style); 'slice' keeps bar width and shows only the trailing bars (web style). + clip_ratio: cap the y-axis at ratio x the median non-zero bucket so a lone + outlier can't flatten the rest. Over-cap bars run to the top row and are + labelled there with their real value + '↑'. None disables it. """ A_DIM = curses_mod.A_DIM y = top - if title: - put(y, 1, title, title_attr) - y += 1 if not series: + if title: + put(y, 1, title, title_attr) + y += 1 put(y, 3, no_data_text, A_DIM) return y + 1 axis_w = 7 plot_x = axis_w + 1 - avail = max_x - plot_x - right_margin - n = len(series) + avail = max(1, max_x - plot_x - right_margin) gap = 1 + # More buckets than columns: aggregate rather than let the tail fall off + # the right edge. put() clips silently, so an un-binned long series drew + # only its *oldest* bars while the y-axis still scaled to the invisible + # newest ones — the whole chart squashed against a max you can't see. + binned = 1 + if overflow == "shrink" and len(series) > avail: + binned = -(-len(series) // avail) + series = _bin_series(series, binned) + n = len(series) if bar_w is None: cap = pref_bar_w if pref_bar_w is not None else max_bar_w bar_w = max(1, min(cap, avail // n - gap)) @@ -58,8 +91,45 @@ def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, else: bar_w = max(1, avail // n) + if title: + if binned > 1: + title = f"{title} · {binned}{bin_unit or ''} per bar" + put(y, 1, title, title_attr) + y += 1 + max_val = max((b["count"] for b in series), default=0) + # Outlier clipping: a single scanner flood 29x the median leaves every + # other bar a 1-cell stub against an axis max nothing else approaches. + # Cap the scale at a robust bound and mark what ran past it. + clipped = set() + if clip_ratio and n >= clip_min_bars: + nz = sorted(b["count"] for b in series if b["count"] > 0) + if nz: + med = nz[len(nz) // 2] + # Floor the cap at p90 as well as ratio x median. On a broad + # spread (hourly counts of 1..180 for one small domain) the ratio + # alone caps just above the median and clips a quarter of the + # bars, flattening the shape it was meant to reveal. + p90 = nz[min(len(nz) - 1, int(0.9 * (len(nz) - 1)))] + cap = max(med * clip_ratio, p90) + over = [i for i, b in enumerate(series) if b["count"] > cap] + # Three ways clipping is the wrong call, all seen in real windows: + # - it would clip a large share of the bars: that's a second mode, + # not an outlier, and the cap would hide real data; + # - the axis barely shrinks: nothing was dominating it; + # - the typical bar is still a stub afterwards, so the ↑ marks buy + # no readability (a domain whose hours run 1..180 is spread out, + # not spiked). + gain = med / cap * plot_h if cap else 0 + if (med > 0 and over + and len(over) <= max(1, len(nz) * clip_max_frac) + and max_val >= cap * 2 + and gain >= 2): + clipped = set(over) + max_val = max([cap] + [b["count"] for i, b in enumerate(series) + if i not in clipped]) + # y-axis with max / mid / 0 labels for i in range(plot_h): put(y + i, axis_w, "│", axis_attr) @@ -67,12 +137,18 @@ def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, label = fmt(int(max_val * frac)) put(row, axis_w - len(label), label, A_DIM) put(row, axis_w, "┤", axis_attr) - put(y + plot_h, axis_w, "└" + "─" * min(n * (bar_w + gap), avail), axis_attr) + slot = bar_w + gap + span = min(n * bar_w + (n - 1) * gap, avail) + put(y + plot_h, axis_w, "└" + "─" * span, axis_attr) + heights = [] for i, b in enumerate(series): - x = plot_x + i * (bar_w + gap) count = b["count"] half = 0 + if i in clipped: + # run to the row below the top; that row carries the real value + heights.append((plot_h - 1, 0)) + continue if max_val > 0 and count > 0: if half_blocks: # Double the vertical resolution: a trailing half-cell is @@ -85,6 +161,31 @@ def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, h = max(1, round(count / max_val * plot_h)) else: h = 0 + heights.append((h, half)) + + def _touches_top(j): + """Whether bar j writes on the top plot row: a clipped bar's label, a + full-height bar (block or ▄ half-cell), or the ▲ of a peak bar one + cell short — the peak glyph sits on the row above the bar.""" + if j in clipped: + return True + h_j = heights[j][0] + heights[j][1] + if h_j >= plot_h: + return True + return (peak_attr is not None and series[j].get("peak") + and h_j == plot_h - 1) + + # Left edge of the unwritten part of the top plot row; clipped-bar labels + # advance it and bars that touch the top row bump it. + top_free = plot_x + + # Value labels need a blank column after them, or adjacent ones smear + # into each other ("85" + "60" reads as "8560"). Where they don't fit the + # y-axis max/mid/0 labels still carry the scale. + for i, b in enumerate(series): + x = plot_x + i * slot + count = b["count"] + h, half = heights[i] segments = b.get("segments") top_attr = color @@ -106,22 +207,64 @@ def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, put(y + plot_h - 1 - h, x, "▄" * bar_w, top_attr) h_eff = h + half - if peak_attr is not None and b.get("peak") and h_eff < plot_h: - put(y + plot_h - 1 - h_eff, x, "▲" * min(bar_w, 1), peak_attr) - - # value above bar — only when there's a clear row above it, so the - # tallest bar's label never lands on the title/axis-max line. - if value_labels and count > 0 and h_eff < plot_h: - vs = fmt(count) - # label_fit: write the full value only when it fits before the - # next bar; a truncated "1.3k"->"1" is worse than no label. - if not label_fit or len(vs) <= bar_w + gap: - put(y + plot_h - 1 - h_eff, x, vs[: bar_w + gap], A_DIM) - - if label_every <= 1 or i % label_every == 0 or i == n - 1: - label = b["label"] - if label_fit and len(label) > bar_w + gap + label_pad: - label = "" + if i in clipped: + # Top row states what ran off the scale. + # ▲ still rides along when the bar is also a peak — dropping it + # here left the tallest bar unmarked while shorter ones kept it. + mark = "▲" if (peak_attr is not None and b.get("peak")) else "" + vs = f"{mark}{fmt(count)}↑" + # The label may spill into neighbouring columns, but only across + # top-row space nothing else touches: stop before the next bar + # that reaches this row (drawn later, it would chop the tail — + # "▲320↑" -> "▲32▲"), and shift left over free space rather than + # degrade, without covering an earlier label or top-touching bar. + limit = min([plot_x + span] + + [plot_x + j * slot for j in range(i + 1, n) + if _touches_top(j)]) + start = x + if start + len(vs) > limit: + start = limit - len(vs) + if start < top_free: + vs, start = (mark or "↑") * bar_w, x + put(y, start, vs, peak_attr if peak_attr is not None else color) + top_free = start + len(vs) + else: + if _touches_top(i): + top_free = max(top_free, x + bar_w) + if peak_attr is not None and b.get("peak") and h_eff < plot_h: + put(y + plot_h - 1 - h_eff, x, "▲" * min(bar_w, 1), peak_attr) + + # value above bar — only when there's a clear row above it, so the + # tallest bar's label never lands on the title/axis-max line. + if value_labels and count > 0 and h_eff < plot_h: + vs = fmt(count) + # label_fit: write the full value only when it fits before the + # next bar; a truncated "1.3k"->"1" is worse than no label. + if not label_fit: + put(y + plot_h - 1 - h_eff, x, vs[:slot], A_DIM) + elif len(vs) <= slot - 1: + put(y + plot_h - 1 - h_eff, x, vs, A_DIM) + + if not label_fit and (label_every <= 1 or i % label_every == 0 + or i == n - 1): put(y + plot_h + label_row_offset, x, - label[: bar_w + gap + label_pad], A_DIM) + b["label"][: slot + label_pad], A_DIM) + + if label_fit: + # Sparse x labels: space them by how wide they actually are instead + # of blanking every one when bars are narrower than a date. Walk + # newest -> oldest so the most recent bucket always keeps its label. + widest = max((len(b["label"]) for b in series), default=0) + step = max(label_every, 1, -(-(widest + 1) // slot)) + wanted = sorted({n - 1} | set(range(0, n, step)), reverse=True) + leftmost = plot_x + span + 1 + for i in wanted: + label = series[i]["label"] + x = plot_x + i * slot + # keep the rightmost label inside the plot rather than letting + # put() clip it to "08-0" + x = min(x, max(plot_x, plot_x + span - len(label))) + if x + len(label) < leftmost: + put(y + plot_h + label_row_offset, x, label, A_DIM) + leftmost = x return y + plot_h + 1 + label_row_offset diff --git a/assets/tui/interact.py b/assets/tui/interact.py new file mode 100644 index 0000000..7c79cda --- /dev/null +++ b/assets/tui/interact.py @@ -0,0 +1,69 @@ +"""Interaction helpers shared by the project TUIs. Stdlib only. + +Extracted from the third copy: `_prompt_search`/`_filtered` existed +verbatim in sh-github-analytics and again in sh-web-security, and `_hbar` +was on its way to a third home. Same admission test as the rest of the +package — none of these knows what a row means. + +These operate on a `framework.TuiApp` (they use `_put`, `stdscr`, `curses` +and reset `scroll`) but take the app as an argument rather than living on +the base class, so `framework.py` stays byte-identical to the copies +already vendored across the portfolio. +""" +from __future__ import annotations + +import contextlib + + +def prompt_search(app, max_len=40): + """Bottom-row `/` prompt; return the stripped input ('' on error). + + Echoes into the footer row with a visible cursor, restores curses + state whatever happens, and resets app.scroll so the filtered list + starts at the top. Callers keep the returned needle app-side (the + convention is `self.search`) and apply it with `filter_rows`. + """ + curses = app.curses + max_y, max_x = app.stdscr.getmaxyx() + app._put(max_y - 1, 0, ("/" + " " * (max_x - 2))[: max_x - 1]) + curses.echo() + with contextlib.suppress(curses.error): + curses.curs_set(1) + try: + raw = app.stdscr.getstr(max_y - 1, 1, max_len) + text = raw.decode("utf-8", "replace").strip() + except curses.error: + text = "" + finally: + curses.noecho() + with contextlib.suppress(curses.error): + curses.curs_set(0) + app.scroll = 0 + return text + + +def filter_rows(rows, needle, key): + """Case-insensitive substring filter on rows[i][key]; [] stays [].""" + if not needle: + return rows + needle = needle.lower() + return [r for r in rows if needle in (r.get(key, "") or "").lower()] + + +def hbar(value, max_value, width): + """Horizontal bar scaled to max_value, clamped to width cells.""" + if max_value <= 0 or width <= 0: + return "" + return "█" * max(0, min(width, int(value / max_value * width))) + + +def cycle(seq, current, step=1): + """Next item of seq after current, wrapping; seq[0] if current is absent. + + The v/V view-and-metric cycling every app wrote as + `VIEWS[(VIEWS.index(self.view) + 1) % len(VIEWS)]`. + """ + try: + return seq[(seq.index(current) + step) % len(seq)] + except ValueError: + return seq[0] diff --git a/references/phase-03-browser.md b/references/phase-03-browser.md index c2a893c..1218346 100644 --- a/references/phase-03-browser.md +++ b/references/phase-03-browser.md @@ -89,20 +89,22 @@ cannot answer: **Do not generate the drawing layer. Copy it.** `assets/tui/` in this skill holds the whole package — `framework.py`, -`charts.py`, `fmt.py`, `windows.py` — 384 stdlib-only lines vendored -byte-identically across three codebases since 2026-07-13. Copy all four: -`charts.py` reaches `windows.py` through a lazy import inside a branch, so a -trimmed copy works until the first stacked bar and then raises. Copy them into the target repository beside your -app: +`charts.py`, `fmt.py`, `windows.py`, `interact.py` — stdlib-only lines +vendored byte-identically across four codebases since 2026-07-13, from the +canonical upstream at [pane](https://github.com/TGPSKI/pane). Copy all of +it: `charts.py` reaches `windows.py` through a lazy import inside a branch, +so a trimmed copy works until the first stacked bar and then raises. Copy +it into the target repository beside your app: ```bash cp -r /assets/tui //tui +# or, with a pane checkout: pane/tools/vendor.sh //tui ``` | Status | Action | |--------|--------| | Target has no TUI package | Copy `assets/tui/` verbatim. Read `assets/tui/README.md` first | -| Target already vendors this framework | Diff against `assets/tui/`; keep the target's copy if identical, and do not "modernize" it | +| Target already vendors this framework | Diff against `assets/tui/` (or run `make vendor-check` in a pane checkout); if it drifted, re-vendor from pane rather than hand-merging, and do not "modernize" it | | Target has a different curses base | Keep theirs. Two frameworks is worse than an older one | You get `TuiApp` (bounds-checked `_put`, colour pairs, a run loop with a