feat(top): scaffold + activity view (S1)#837
Conversation
Introduces /top — a live ratatui-powered Postgres monitor inspired by
pg_top and pgcenter top. S1 is the MVP demo: a single Activity view over
pg_stat_activity with a server-summary header, q/Esc/Ctrl-C exit, and
keyboard navigation. View switching, drill-down, blocking tree, kill
actions, and AI hand-offs land in S2–S7 per .samo/spec/top/SPEC.md.
What lands in this PR
- `/top` — interactive TUI (alt-screen, mouse capture, raw mode RAII guard)
- `/top --once` — headless one-shot snapshot for CI / scripting / evidence
- Activity view: pid, user, db, app, client, backend, state, wait, qtime,
xtime, query — wide layout ≥120 cols, narrow layout drops app/client/backend
- Header: db, user, pg version, primary/standby, uptime, "as of" ts,
connection LED, active/idle-in-tx/wait/total counts, stale-tick badge
- Footer: keymap hints; replaced by red error banner when sampler fails
- Theme: truecolor detection (mirrors /ash) with named-color fallback
- "terminal too small" stub <80×24 (matches /ash policy)
- 35 unit + snapshot tests (state, sampler, sql, renderer, theme, args)
Module layout mirrors `/ash` so reviewers can pattern-match:
src/top/{mod,state,sampler,sql,renderer,theme}.rs
src/top/views/{mod,activity}.rs
Adjacent fix
- exec_command now dispatches `/`-extension commands. Previously only
`\`-meta-commands were recognized in `--command`/stdin mode, so any
rpg-extension command (`/top`, `/dba`, `/ash`, …) passed via -c fell
through to SQL execution and raised a syntax error. Five-line addition
matching the existing `\` branch; mirrors the interactive REPL.
Tests (red/green TDD)
- 35 new tests in src/top/. Snapshot tests use ratatui's TestBackend so
they are deterministic across machines.
- One bug caught by manual integration testing: extract(epoch from …)
returns numeric, which tokio-postgres cannot deserialize into f64.
Wrote a regression test asserting the SQL casts to ::float8, then
fixed sql.rs.
Manual evidence (PG 17.9, local Docker)
- `rpg --command "/top --once"` against a DB with three sleeping clients
produces the expected 9-row snapshot — ordered active first, then by
qtime desc, with system backends below. Full output captured at
/tmp/rpg_top_evidence.txt; pasted into the PR description.
Out of scope (deferred to later sprints in SPEC.md)
- View switching, sort/filter, drill-down, blocking tree, sparklines,
kill actions, pause/rewind, JSON output, AI hand-offs (Shift-X/I),
`rpg top` clap subcommand alias, system-stats reader.
Refs: .samo/spec/top/SPEC.md (v0.2)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's clippy 1.95 flags `Result.map(...).unwrap_or(false)` as `clippy::map_unwrap_or`. Local clippy is 1.94 and didn't catch it. Rewrites the truecolor probe with `is_ok_and`, which is what clippy 1.95 suggests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
REV Code Review Report
BLOCKING ISSUES (5)HIGH
HIGH
HIGH
MEDIUM
MEDIUM
POTENTIAL ISSUES (5)MEDIUM
MEDIUM
MEDIUM Terminal-escape injection via unsanitized
MEDIUM
MEDIUM
NON-BLOCKING (15)LOW Summary
Result: 5 BLOCKING items. Will fix and re-CI/re-REV before merge per project workflow (CLAUDE.md PR workflow + memory: never merge without explicit user approval). REV-assisted review (parallel AI analysis: security, bugs, tests, guidelines, docs). |
Addresses the 5 BLOCKING + 3 high-value POTENTIAL findings from the REV review on PR #837. All fixes follow red/green TDD: the test that proves the bug is committed before the fix. BLOCKING (5) - T1: tests/top_smoke.rs — integration smoke test against the PG14-18 matrix via --features integration. Spawns the rpg binary, runs `--command "/top --once"`, asserts exit 0 + chrome content. Includes explicit guard against the `error deserializing column 9` regression fixed earlier (extract(epoch …)::float8). Three tests: idle render, backend-count caption, unknown-/-command routing. - T2: extract `is_slash_extension_command` from exec_command's new / branch and add unit tests covering recognition (/top, /dba, leading whitespace) and rejection (SQL, backslash metas, mid-statement /). - T3: connection_led_color_reflects_freshness — assert the cell-level `style().fg` flips between status_ok and status_stale when stale_ticks crosses zero. Catches a bug that swapping the two arms would otherwise pass every dump-substring test in the suite. - D1: remove dangling intra-doc link to SUPPORTED_PG_VERSION_HINT (the constant was deleted in cleanup; the //! reference lingered). - D2: SPEC.md §3.1 + ASCII diagram — correct `run_top` signature to match S1 reality (`&Client`, inline loop). Note the Arc<Mutex<Client>> + separate-task design as a future-sprint concern. POTENTIAL → addressed (3) - S1: scrub_terminal_unsafe — strip every control character (ESC, BEL, BS, DEL, C1 range) from every pg_stat_activity-derived string before it reaches ratatui. application_name and query are settable by any connected Postgres client (`SET application_name = E'\x1b[2J...'`); a low-privileged client could otherwise inject ANSI escapes that another DBA's terminal would execute. Whitespace controls collapse to a space; everything else drops. Tests: per-helper (truncate, squash_query) plus an end-to-end renderer test that scans the entire cell buffer for ESC/BEL bytes. - B3: format the snapshot timestamp as `HH:MM:SS UTC` instead of `T1700000000`. Pure modular arithmetic — no chrono dep needed. - D3, D8: strip stale procfs/system-stats node and edge from the architecture diagram + json (system stats are explicitly out of v1 per §4.10); flag `/top --once` in docs/COMMANDS.md; correct `query_id from PG14` wording. Deferred (POTENTIAL, low priority) - B1: exec_command's / branch always returns 0. Distinguishing matched vs unmatched needs a richer return type from dispatch_ai_command; documented inline as a known limitation. Tests - 9 new unit tests (renderer, activity view, repl predicate) - 3 new integration tests (tests/top_smoke.rs, --features integration) - All previous tests still pass: 2110 unit + 41 ignored, no regressions - Verified locally against PG 17.9 (port 55433): 1.38s for the 3-test integration smoke run Refs: REV report — #837 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
REV blockers addressed — re-CI greenCommit
Deferred (POTENTIAL, non-blocking)
Test count delta
All 2110 unit tests pass; all 3 integration tests pass against local PG 17.9 (and against the CI matrix PG14–18, see below). CI on
|
Mirrors the pattern used for the other demos (vhs tape + bg workload script): top-workload.sh spawns a self-contained mix of active queries, idle-in-tx and advisory-lock contention so the Activity view has live content; top-demo.tape drives rpg through `/top` (interactive TUI with cursor navigation) and `/top --once` (headless snapshot). Tape config matches ash-cursor-demo: 1400×800, FontSize 15, Dracula, zsh, 30 ms typing speed. Workload connects via psql with explicit CONNINFO so it works against any cluster — no schema dependencies (uses generate_series, pg_sleep, advisory locks). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Demo gifRecorded with VHS, following the
The recording shows the interactive |
- 1.5 s pause between typing and Enter for `rpg`, `/top`, `/top --once`, `\q`, so the viewer can read what is about to be executed - Slightly longer dwell on the active TUI (6 s post-launch) - Inline comment block lists the smart features visible during the live TUI segment (connection LED, HH:MM:SS UTC clock, state/wait/qtime coloring, responsive ≥120-col layout) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updated demo + smart functions clarificationPacing fix. Pushed (GitHub caches the GIF; the "Where are smart functions demonstrated?"Honest answer: this PR is S1 of 8 per
The genuinely interactive smart features — fuzzy filter ( If you'd like me to pull any specific S2–S7 item forward into this PR (or cut a follow-up PR for, say, |
…ticky header, richer header User feedback round on PR #837: Wait events - delimiter `:` (e.g. `IO:DataFileRead`), matching pg_ash convention - 24-bit RGB palette imported from pg_ash COLOR_SCHEME.md (CPU=#50FA7B, IdleTx=#F1FA8C, IO=#1E64FF, Lock=#FF5555, LWLock=#FF79C6, IPC=#00C8FF, Client=#FFDC64, Timeout=#FFA500, BufferPin=#00D2B4, Activity=#9664FF, Extension=#BE96FF, Other=#B4B4B4) - new wait_color_for_row helper covers all 12 pg_ash buckets Locks held column - SUMMARY/ACTIVITY queries left-join pg_locks (granted = true) per pid - locks column visible in both default and extended layouts - "-" rendered when count is zero Configurable refresh rate (like pg_top / pgcenter top) - DEFAULT_REFRESH_SECS = 1.0 (constant in state.rs) - CLI: `--refresh <n>` and `-s <n>` (range 0.1..=60.0) - interactive: `s` opens a footer prompt seeded with the current value; digits/`.`/Backspace edit, Enter applies, Esc cancels, q is swallowed while prompt is open - new run_top loop reads app.refresh_secs each tick so changes take effect immediately Extended-mode toggle - app/client/backend now opt-in (toggled by `e`); default columns fit 80 cols cleanly: pid/user/db/state/wait/qtime/xtime/locks/query - extended layout adds app/client/backend in front of state (still needs ≥120 cols) - title bar shows " [extended] " badge when active Sticky table header - switched to render_stateful_widget with TableState; row_highlight_style gives the inverse-video selection while ratatui keeps the column header pinned during scroll Richer two-line header (PG-level only) - max_connections (so total/max is meaningful: "total 47/100") - longest_xact_secs, longest_active_query_secs (compact human format via format_secs_or_dash) - cumulative deadlocks_total + temp_files_total (pg_stat_database sums) - header bumped from Length(3) to Length(4) for two inner rows Demo - workload spawns persistent idle + idle-in-tx backends (psql via stdin so the connection stays alive between commands) - one idle-in-tx holds an advisory xact lock + creates a temp table to seed the locks_held column - tape demonstrates: cursor scroll past the visible window (sticky header), `e` toggle, `s` 0.5 prompt, `/top --once` - 1.5 s pause between typed text and Enter - gif optimised through gifsicle (4 MB, 64 colors) Tests - 64 unit tests in top:: (was 41); covers state, sampler, sql, renderer, theme, args, prompt state machine, extended toggle, pg_ash routing - all 2132 unit tests pass; 3 integration smoke tests pass against PG14-PG18 in CI (verified locally against PG 17.9) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 3 — feedback appliedPushed
Test counts
Local: clippy Awaiting CI re-run on the new commit; will follow up if anything breaks. |
clippy 1.95 flags a guarded `Char(c) if pred` arm that wraps another `if` in its body as `collapsible_if`. Move the length check into the match guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's rustfmt collapses the long match-arm pattern with a guard onto a single line; my local rustfmt 1.x broke it across three. Apply the CI form so cargo fmt --check passes everywhere. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updated gif (post round-3 changes)Same file, just re-pinning the link since the CI fix-loop comments pushed it down. The gif at Visible in this recording:
PR head: |
Round-4 user feedback on PR #837: Sort column indicator (top/htop/btop style) - new SortColumn enum on App; default Qtime descending - `<` / `>` cycle through Pid, User, Db, State, Wait, Qtime, Xtime, Locks, Query (wraps around); changing column resets direction to that column's sensible default (numerics descending, textuals ascending) - `r` reverses direction without moving columns - active column header is underlined and carries `▼` (desc) / `▲` (asc) - sorting happens in Rust against the snapshot — no SQL re-execution, so the change is instant Key-press overlay (corner indicator) - new KeyOverlay { label, expires_at } stored on App; populated by every interactive keystroke, suppressed inside the prompt so each typed digit doesn't strobe - rendered in the upper-right of the body area as ` ⌨ <label> ` for KEY_OVERLAY_TTL = 1.2 s, then disappears - special keys get glyphs: ↑ ↓ ← → ↩ ⌫ Esc PgUp PgDn Home End Tab; chars render as themselves; Ctrl-X as `C-x` Continuous batch logging mode (`--batch` / `-b`) - prints a timestamped text snapshot every refresh_secs to stdout until SIGINT — for `tmux pipe-pane`, `> top.log`, etc. - shares the rendering path with `--once` (same TestBackend dump); only the loop + timestamp prefix is new - separator: `===== <strftime-formatted timestamp> =====` - `--ts-format <fmt>` accepts a strftime subset: %Y %m %d %H %M %S (zero-padded), %T (HH:MM:SS), %F (YYYY-MM-DD), %s (epoch), %z (+0000), %Z (UTC), %% (literal %); unknown specifiers pass through verbatim - default format: `%Y-%m-%dT%H:%M:%SZ` - calendar arithmetic via Howard Hinnant's `civil_from_days` so we don't pull in chrono just for log timestamps - graceful Ctrl-C exit via `tokio::signal::ctrl_c` + `tokio::select!` Demo - tape now drives `> > > r < <` to walk the active-sort indicator through several columns and reverse direction, with the key overlay labelling each press as it lands - footer hint line updated: `</>` sort, `r` reverse Tests - 82 unit tests in top:: (was 64); covers SortColumn::step wrapping, direction reset on column change, KeyOverlay TTL expiry, prompt suppresses overlay, format_strftime token table, anchor-point calendar dates (1970-01-01, 2023-11-14, 2020-02-29 leap), unknown specifier passes through - --batch / --ts-format CLI parsing - all 2150 unit tests pass; 3 integration tests pass against PG14-18 Local smoke (PG 17.9 on :55433): $ rpg --command "/top --batch --refresh 2 --ts-format '[%F-%T-UTC]'" ===== [2026-05-08-19:13:35-UTC] ===== ┌ rpg /top ────… … Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback round on PR #837: Space → immediate refresh - new `force_refresh: bool` on App, set by Space; the run_top inner event-poll loop breaks early so the next sampler tick fires now (matches `top`'s "redisplay now" convention) - footer hint updated: `Space refresh` - test asserts the flag round-trips Wide-screen chrome contrast fix - DarkGray-on-dark was invisible on many themes (the user reported invisible header labels and missing borders). Bumped both `theme.muted` and `theme.border` to a brighter gray pair tuned for truecolor (RGB) with `Color::Gray` 256-color fallback - restored a right-aligned cell on each header row: clock+UTC on row 1, connection LED (and stale badge when present) on row 2 — gives wide terminals visual structure instead of leaving the right side blank - layout: header inner is split horizontally Min(20)/Length(28) on row 1 and Min(20)/Length(20) on row 2 Help / docs - `/top` now appears in `/?` (rpg's help text) under AI commands, matching the `/ash` entry; one-line summary covers `--once` / `--batch` flags - docs/COMMANDS.md `/top` line was already updated in the previous round; help_text() in src/repl/mod.rs needed the same line Tests - 83 unit tests in top:: (was 82); covers Space → force_refresh - all unit tests pass; clippy strict (-D warnings) + fmt clean Demo gif re-rendered against the new chrome — visible borders, visible labels, visible tabs strip. (4.8 MB gifsicle-optimised.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 5 — wide-screen contrast, Space-refresh, help, batch mode, sort indicator, key overlayPushed
Footer hints (default)Quick batch-mode exampleTest counts
clippy |
User feedback round on PR #837: ← / → arrow sort cycling - new keybindings alongside existing < / >; r still reverses (matches traditional top's R) - vim-style j/k cursor binding removed: k now triggers pg_cancel_backend, and a vim user pressing k for cursor-up by reflex on a wide table is exactly the surprise we want to prevent - test asserts Left/Right round-trip, plus a regression test that catches anyone re-adding vim-k k / K cancel / terminate, with footer y/N confirmation - k → pg_cancel_backend(selected.pid), K → pg_terminate_backend - confirmation captures the selected row at keystroke time (pid, user, db, state, qtime, query summary) so a re-sort between key and y doesn't change the target - footer flips to: " CANCEL pid 12345 (user@db, active 42.0s, ‹update accounts set …›)? [y/N] " (red badge when TERMINATE) - y/Y fires; n / N / Esc / anything else cancels without firing - run_top loop hands the SQL to a `run_kill` helper, which sets a 5 s statement_timeout, calls the function, and writes a footer admin message ("OK CANCEL pid 12345" / red "ERR …") with a 5 s TTL - background workers (pid <= 0 in our pg_stat_activity model) are no-ops; the prompt won't even open Header gets a 3rd line with operational stats - SUMMARY_SQL adds: autovacuum_busy / autovacuum_max workers, pg_replication_slots counts split by physical / logical (active out of total) - header height bumped from 4 to 5 rows; new line: longest-tx Ts longest-q Ts deadlocks N temp-files N av busy/max slots Aphys/Tphys p Alog/Tlog l - pre-existing longest-tx/longest-q/deadlocks/temp-files moved off line 2 so each line has a coherent topic (line 2 = workload counts; line 3 = ops/internals) Other - footer hint refreshed: q quit · ↑↓ move · Space refresh · ←→ sort · r reverse · k/K cancel/term · e extended · s set delay - help_text() in src/repl/mod.rs already lists /top — no change - docs/COMMANDS.md /top entry now enumerates the full keymap and the parallel-pane pattern (interactive /top + `/top --batch > log`) - demo tape walks: scroll · sort cycle · reverse · extended toggle · s 0.5 prompt · k confirm (decline) · K confirm (decline) · --once Tests - 93 unit tests in top:: (was 83); covers Left/Right cycling, k/K confirmation flow, y promotes confirm to pending, n/Esc cancels, no-op on empty table or pid 0, kill-confirm prompt rendering, OK/ERR admin badge rendering, ops-line autovacuum + slot output - all unit tests pass; clippy strict (-D warnings) + fmt clean Manual smoke (PG 17.9): /top with a victim pg_sleep(60), pressed k, confirmed y, pg_cancel_backend returned t, the row vanished on the next tick, and the OK badge appeared in the footer for ~5 s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 6 — k/K cancel/terminate, ←→ sort, autovacuum/slots, parallel batchPushed
Footer keymap (default)Vim-
|
| Before | After | |
|---|---|---|
Unit tests in top:: |
83 | 93 |
| Total unit tests | 2151 | 2161 |
Integration tests (--features integration) |
3 | 3 |
clippy -D warnings clean, fmt clean. CI re-running on 669d86a.
User feedback round on PR #837 + round-6 CI lint failure. CI lint fix - clippy 1.95 fired `doc_list_overindented` on the module docstring in src/top/state.rs. The bullet list of S1 keybindings used a 2-space indent (`//! - foo`) — clippy now wants flush-left or 1-space. Reformatted as plain markdown bullets and tightened the list at the same time. "Activity" no longer rendered twice - tabs strip already prints "Activity"; the body title bar was printing it again. Body title is now caption-only: `(N rows) [extended]`. The view name lives only in the tabs strip. Key-press overlay → opt-in (recording aid only) - new `--show-keys` flag on TopArgs (default off). Sets `App.show_keys`; only when on does `handle_key` seed `last_key`, so an interactive operator never sees the overlay flashing in their face. The demo tape passes `--show-keys` so the gif explains itself. - regression test: `key_overlay_off_by_default` asserts that pressing a key without `show_keys = true` leaves the overlay empty (state.rs and renderer.rs have one each). Overlay relocated and styled per spec - moved from upper-right to **lower-left** of the body area - now a 3-row solid-yellow billboard, bold-black foreground, with extra padding around the label so it reads as visually loud: ┌──────────────┐ │ │ <- yellow bg │ ⌨ PgDn │ <- yellow bg, bold black │ │ <- yellow bg └──────────────┘ - earlier draft used `▄` / `▀` half-blocks for an even bigger silhouette but some gif renderers dropped those glyphs; plain spaces with yellow bg survive cleanly through optimisation - tested at 96-colour gifsicle: overlay survives quantisation Other - 95 unit tests in `top::` (was 93) - clippy strict + fmt clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, modifier prefixes User feedback round on PR #837: Global `--show-keys` rpg flag - new top-level CLI flag (`rpg --show-keys ...`) and matching `ReplSettings.show_keys` field - intent: turn on the on-screen key-press badge for *any* TUI rpg command from a single switch, so a recording rig can capture demos of the bare REPL, /top, /ash, /rpg, etc. with a single env knob - currently honored by /top; /ash and the bare REPL prompt overlay land in follow-up PRs (renderer surfaces are different) - the per-/top `--show-keys` arg still works; the global flag wins Overlay relocated and bigger - moved from lower-left to lower-right corner (user request) - label glyphs are upscaled to 2x cell width using Unicode "fullwidth" forms (`D` → `D`, `o` → `o`, etc.); arrow keys swap to chunkier triangular variants (`↑↓←→` → `▲▼◀▶`). This is the only way to visually double a glyph inside a fixed-cell terminal — the cell grid itself can't change mid-frame. - the leading "⌨" decoration is gone (user explicitly removed it) - bg colour is now a muted olive-yellow `#A08018` instead of full-saturation `Color::Yellow`; reads as ~50 % alpha over a dark terminal without erasing the underlying body content. Real alpha isn't a thing in the VT100 cell model. Modifier handling - `Ctrl-X` → `C-X`, `Alt-X` → `M-X`, `Super-X` → `S-X`, stack as `C-M-X`, `C-S-X`, etc. (`top`-style prefix convention) - Shift on a printable key arrives as the shifted char itself (`Char('K')` for Shift-K), so we don't add a redundant "⇧". The canonical Shift-Tab keyword `BackTab` still gets an explicit `⇧Tab` label. - macOS Terminal.app strips Alt/Super on the way through; that's a terminal limitation, not ours. Tests - 96 unit tests in top:: (was 95): adds `modifier_keys_get_compact_prefixes` covering Ctrl, Alt, stacked C-M-, Shift-on-printable, BackTab - overlay-presence tests rewritten to check the bg color of any cell in the buffer (now that the "⌨" sentinel is gone, the safest signal is the muted-yellow bg itself) - full clippy strict + fmt clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User screenshot feedback on round-8: the muted olive bg looked dirty,
the right edge of the box was notched (fullwidth glyph render mismatch),
the fullwidth letters looked squashed, and the whole thing was too small.
- bg switched from `Rgb(0xA0, 0x80, 0x18)` (muted olive) back to the
bright named `Color::Yellow`. Terminals map the named color to
their theme's clean yellow rather than a fixed RGB tone.
- dropped Unicode fullwidth conversion (`Pgdn` → `PgDn`). Some
cell-width estimators disagreed with the actual rendered width and
that produced the notched right edge / squashed-character feel.
Plain ASCII labels are aligned and clean.
- box height bumped from 3 to 5 rows, with a 14-cell minimum width
and 10 cells of horizontal padding around the label. The badge
now reads as a proper "key pressed" callout instead of a strip.
- kept the chunky-arrow swap (`↑↓←→` → `▲▼◀▶`) so single-glyph
overlays still look chunky.
Tests
- 96 unit tests in top:: (no count change). The bg-color sentinel
used by `buffer_has_overlay_bg` updated from the muted RGB to
`Color::Yellow`.
- clippy strict + fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
clippy 1.95 fires `unnecessary_trailing_comma` on a single-line `format!` macro call. CI lint failed on round-9; local clippy 1.94 didn't catch it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live demo gif (rolling — edited in place)PR head: Round-12 (commit Figlet font from round-11 unchanged: Box style: bright Round summary
(I'm editing this comment in place when the gif updates instead of stacking new comments.) |
User feedback: the character pressed should be 5x bigger; box stays the
same. Implemented via a hand-built 5×5 block-letter font living in
src/top/keyfont.rs. Single-character labels (`e`, `K`, `r`, `↓`, `<`, …)
now render as chunky 5-row figlet glyphs filling the box; multi-letter
labels (`PgDn`, `Esc`, `Home`, …) keep the centred plain-text fallback.
- new module `src/top/keyfont.rs` with a `glyph(char) -> Option<[&str; 5]>`
lookup. Covers A–Z (case-insensitive), 0–9, common punctuation
(`< > / - + = ? ! :`, space), and the four cursor arrows (`↑↓←→`,
`▲▼◀▶`). Unknown chars return `None` so the renderer can fall
back gracefully.
- render_key_overlay branches: known single char → 5×5 glyph filling
all 5 rows of the yellow box with 3 cells of horizontal padding;
everything else → existing plain-text path.
- tests in keyfont.rs assert every glyph is exactly 5×5, lowercase
maps to upper, arrow synonyms (`↑` ≡ `▲`) tie out, and unknown
chars yield `None`. 100 unit tests in top:: total (was 96).
- clippy strict + fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback on round-10: "omg too big" — the 5×5 figlet glyph was right but the surrounding box was over-padded (3 cells of yellow on each side). Cut the side padding from 3 to 1 so the box hugs the glyph: 7 cells wide × 5 rows tall, ~64 % the area of the previous visual. (On the gif's clock skipping seconds: vhs simulates time at its render speed, while rpg renders the real wall clock from `pg_postmaster_start_time` deltas, so the two diverge whenever vhs slows down on a heavy frame. Pure render artefact, no rpg-side fix.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous 5×5 block-letter font dwarfed the rest of the TUI and made multi-char labels (PgDn, Esc, Home) inconsistent with single-char ones — they fell back to plain bold text and looked tiny next to the giant arrows and letters. Switch to a compact 3×5 cell glyph grid and a `render_label` helper that walks every char of a label through the same glyph table, so PGDN, ESC, HOME render at the exact same scale as ▲, K, /. Box height drops to 3 rows, side padding tightens. Visual result: still readable while recording, no longer eats half the screen. Some glyphs collapse to identical 3×5 forms (I/Z, V/Y); the lookup is annotated `#[allow(clippy::match_same_arms)]` so future tweaks can keep them as separate, named arms without contorting the shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
REV-assisted review (round 11) —
|
| Agent | Status | Findings |
|---|---|---|
| Security | PASS | 2 POTENTIAL |
| Bugs | BLOCKED | 1 BLOCKING + 4 POTENTIAL/INFO |
| Tests | PASS | 4 NON-BLOCKING/INFO |
| Guidelines | BLOCKED | 1 BLOCKING + 3 POTENTIAL/INFO |
| Docs | BLOCKED | 1 BLOCKING + 5 POTENTIAL/INFO |
BLOCKING
[Bugs] src/top/state.rs:555 — request_kill targets a different pid than the highlighted row.
The renderer sorts a fresh slice via views/activity.rs:99-100's sort_rows, but request_kill reads snap.rows.get(self.selected_row). The TableState.with_selected(...) highlight indexes the sorted slice. After any non-default sort (and even on the default, since the SQL ORDER BY differs from the Rust qtime-desc tiebreak), pressing k/K builds the [y/N] confirm and ultimately kills a backend the user did not intend.
Fix: sort once in
App::set_snapshot(or haverequest_killre-runsort_rows) and picksorted[selected_row]. Add a regression test with ≥2 rows where the SQL order and Rust sort disagree.
[Guidelines] demos/top-workload.sh — every psql invocation violates the PostgreSQL command execution rule.
Lines 32, 37, 48, 56, 59, 64, 68, 72, 77 all use psql -c "…" without --no-psqlrc and PAGER=cat, and use the short -c form rather than long-option-per-line. CLAUDE.md's "PostgreSQL command execution" rule is unconditional.
Fix: prefix each call with
PAGER=cat psql --no-psqlrc --dbname="${CONNINFO}" --command="…".
[Docs] docs/COMMANDS.md:49 — /top row omits --show-keys.
The flag is implemented (src/top/mod.rs:130) and exposed globally (src/main.rs:351); the demo tape relies on it. Missing entirely from the public reference.
Fix: append
`--show-keys` (overlay each keypress as a billboard demo aid)to the flags list.
POTENTIAL (recommended, not blocking)
- [Security]
renderer.rs:455-458—build_kill_confirm_lineinterpolates rawreq.usename/req.datname/req.state/req.query_summarywithoutscrub_terminal_unsafe. A DB user controllingapplication_namecould inject ANSI/BEL into the operator's terminal when the operator pressesk/Kon that backend. Activity-table fields are correctly scrubbed viatruncate/squash_querybut the kill-prompt path is not. - [Security]
renderer.rs:486—build_admin_message_linerendersmsg.textdirectly. In the kill error branch the text isformat!("{e}")fromtokio_postgres::Error, which can carry server-controlled bytes. - [Bugs]
state.rs:541-548—handle_kill_confirm_keycancels on_. Bare modifier-only key events (Kitty CSI-u, Windows release events) would silently dismiss the confirm. - [Bugs]
mod.rs:300-308—run_killignores the result ofset statement_timeout = 0. If the reset fails the 5 s timeout leaks to subsequent sampler ticks. - [Bugs]
sql.rs:36—total_backendsexcludespg_backend_pid(), so the header is off by one vspg_stat_activity. - [Guidelines] 12 of 15 commit subjects in this delta exceed 50 chars. Resolved by squash-merging into a single PR-scoped commit.
- [Docs]
docs/COMMANDS.md:49— Keybinding list is missingPgUp/PgDnandHome/End(both wired in S1). - [Docs]
.samo/spec/top/SPEC.md— Status still "ready for S1"; doesn't reflect what shipped (--batch,s/r/e, kill confirm). - [Docs]
.samo/spec/top/decisions.md— No "S1 shipped" entry recording deviations.
NON-BLOCKING / INFO
- [Tests] No smoke test for
--batchmode end-to-end;format_refresh_seedonly covered transitively;render_label("C-K")mixed-glyph case missing. - [Bugs]
KeyModifiers::SUPER → S-collides with the Shift convention. - [Bugs]
renderer.rs:373— uncheckedinner_w + 2arithmetic; considersaturating_add. - [Guidelines]
demos/top-workload.shlacksmain()+main "$@"last line. - [Security]
sql.rsSQL is fully static;run_killbinds pid as$1; figlet glyph table consumes only localKeyEvents — all clean.
Next step: fix the 3 BLOCKING items (kill targets the right row, shell rule, doc gap) plus the 2 security POTENTIAL scrubs (cheap, prevents a real terminal-injection class), push, re-run CI + REV.
Three blockers:
1. `request_kill` (state.rs) read `snap.rows[selected_row]`, but the
renderer sorts a fresh slice via `views::activity::sort_rows` and
the cursor indexes that sorted slice. Result: pressing `k`/`K`
after any non-default sort confirms — and ultimately kills — a
different backend than the one highlighted. Fixed by sorting in
`request_kill` with the same column/direction the renderer uses,
then picking `sorted[selected_row]`. Regression test pins it
(Pid-asc with two rows whose SQL order disagrees).
2. `demos/top-workload.sh` violated CLAUDE.md "PostgreSQL command
execution" — `psql -c "…"` everywhere with no `--no-psqlrc` and
no `PAGER=cat`. Rewrapped via `run_psql` / `run_psql_stdin` helpers
that apply the project conventions; restructured into a `main`
block so `main "$@"` is the last line per shell-style-guide.
`env PAGER=…` form to keep shellcheck quiet.
3. `docs/COMMANDS.md` `/top` row omitted `--show-keys` and the
PgUp/PgDn/Home/End keybindings. Added.
Plus two POTENTIAL security findings folded in while we're touching
the renderer:
- `build_kill_confirm_line` interpolated raw `usename` / `datname` /
`state` / `query_summary`; a DB user controlling `application_name`
could inject ANSI/BEL bytes into the operator's terminal at the
moment they press `k`. Now scrubbed via `scrub_terminal_unsafe`.
- `build_admin_message_line` rendered `msg.text` directly; the kill
error branch builds it from `format!("{e}")` against a
`tokio_postgres::Error` which can carry server-controlled bytes.
Now scrubbed.
New tests cover both kill-confirm and admin-message scrubbing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vhs's native gif quantizer drops frames during the busy workload section of the demo, which the user (correctly) noticed as laggy. The mp4 it produces in parallel is fine, so re-encode through ffmpeg's palette pipeline (15 fps, 1200 px wide, bayer dither) for smooth playback. Cost: 3 MiB → 11 MiB. Worth it for a feature gif where the smoothness *is* the demo. `demos/render-top.sh` wraps the two-step process so future re-renders are reproducible. The tape now also writes `top-demo.mp4` (gitignored) as an intermediate artifact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
REV r12 audit caught it: macOS BSD `mktemp -t prefix` does not treat `XXXXXX` as a template — it appends a random suffix to the whole string, so the requested `top-demo-palette.XXXXXX.png` lands at `…palette.XXXXXX.png.RANDOM`. The trailing extension is the random hash, not `.png`, so ffmpeg's image2 muxer fails to infer the codec and aborts with `Invalid argument` before writing the palette. The gif we shipped in 3182ebc was produced by an ad-hoc invocation against `/tmp/palette.png`, not by running the script — so the script as committed would have failed for anyone else regenerating the demo. Switch to an explicit `${TMPDIR:-/tmp}/top-demo-palette-$$.png` path that keeps the `.png` extension intact and is still cleaned up by the existing EXIT trap. While in here, fix the `demos/README.md` "render all at once" loop to skip `top-demo.tape` and call `render-top.sh` separately — running plain `vhs` on it produced exactly the laggy gif this whole chain was written to avoid. Verified: `bash demos/render-top.sh` now runs end-to-end and writes a fresh ~11 MiB gif. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
REV-assisted review (round 12 follow-up) —
|
| Round-11 finding | Status | Evidence |
|---|---|---|
request_kill targets unsorted slice → wrong-pid kill |
RESOLVED | state.rs:560-562 re-sorts via views::activity::sort_rows(&mut sorted, sort_column, sort_desc) and indexes the sorted slice; regression test k_targets_the_sorted_row_not_the_sql_row (state.rs:1172) RED→GREEN with two rows whose SQL order disagrees with Pid-asc |
demos/top-workload.sh violates psql shell rule |
RESOLVED | every call now goes through run_psql / run_psql_stdin helpers that apply env PAGER=cat psql --no-psqlrc --dbname=… --command=…; main() block; main "$@" last line; shellcheck clean |
docs/COMMANDS.md /top row missing --show-keys |
RESOLVED | --show-keys now documented; PgUp/PgDn and Home/End keybindings listed |
Security POTENTIAL resolution
| Finding | Status | Evidence |
|---|---|---|
build_kill_confirm_line un-scrubbed row fields |
RESOLVED | renderer.rs:464-467 wraps each field in scrub_terminal_unsafe; new test kill_confirm_strips_ansi_escapes_from_row_fields (renderer.rs:1007) seeds \x1b[31m / \x1b[2J / \x07 and asserts no control byte reaches the rendered buffer |
build_admin_message_line un-scrubbed msg.text |
RESOLVED | renderer.rs:499 scrubs the text; admin_message_strips_ansi_escapes test (renderer.rs:1047) seeds an ANSI/BEL string into the kill-error footer and asserts byte-clean output |
New findings this round
[POTENTIAL]→ fixed indemos/render-top.shmktemp -t …palette.XXXXXX.pngis broken on macOS BSD5db967b: switched to${TMPDIR:-/tmp}/top-demo-palette-$$.png; verified end-to-end run produces an 11 MiB gif. The audit caught a real bug — ffmpeg refused to write the palette because the extension was the random hash, not.png. The committed gif from3182ebcwas produced by an ad-hoc ffmpeg invocation against/tmp/palette.png, not the script, so the script-as-committed would have failed for anyone else.[INFO]→ fixed indemos/README.md"render all at once" loop runs plainvhsontop-demo.tape, skipping the post-process and producing the laggy gif this chain was written to avoid5db967b: loop now skipstop-demo.tapeand the README points atbash demos/render-top.shfor the post-processed render.- [INFO]
demos/top-workload.sh:48-65— three persistent-backend( … ) &blocks fire at top-level beforemain()is invoked. Letter of the shell-style rule met (main()exists,main "$@"is the last line); spirit (whole procedural body insidemain) drifts a little. Cleanup is trapped on EXIT so behaviour is correct. Cosmetic. - [INFO]
demos/top-demo.gif— file size grew 2.9 MiB → 11 MiB. Documented indemos/README.mdas the deliberate cost of the smoother ffmpeg pipeline.
Verdict
Status: PASS — all round-11 BLOCKING and POTENTIAL security findings resolved with regression tests; the only sharp follow-up the audit raised (BSD mktemp) is fixed in 5db967b. Remaining INFO items are cosmetic.
Per project memory rule, not merging without explicit approval.
Remove private format_secs_or_dash (identical logic to activity::format_secs) and truncate_err (identical to activity::truncate) — promote both to pub(in crate::top) and import them in renderer.rs. Drop unused _app parameter from build_counts_line. Hoist Terminal<TestBackend> construction out of the run_batch loop so the same backend is reused across ticks rather than reallocated each time. New tests: - k_targets_the_sorted_row_not_the_sql_row (regression for request_kill bug) - k_is_a_no_op_for_negative_pid_rows - k_targets_sorted_row_under_qtime_sort - page_down_saturates_at_last_row - sort_right_edge_wraps_from_query_to_pid - set_snapshot_clears_last_error - expired_admin_message_falls_back_to_default_footer Fix lock-count fixture values (47/83) to avoid collision with pid digits. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
\x1b[2J was sent to stdout after LeaveAlternateScreen, which wiped the user's main terminal content (previous query results, shell prompt, etc.). Alt-screen restoration already preserves the original terminal state; the explicit erase was both wrong and redundant. Remove it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
REV-assisted review — round 12 (commit 0d57770 + c6c5db9)CI Status: ✅ pass (all core checks: lint, test, clippy, MSRV, PG14–18, connection, regress, wasm, CodeQL) BLOCKING issues fixed in this review cycleFIXED ·
NON-BLOCKINGSecurity · N-1:
Security · N-2:
Bugs · N1: Scripts relying on Bugs · N2: The stale-tick badge that shows in interactive mode when the DB is slow will never appear in Tests · 1: The two-pass approach (map control bytes to Tests · 2: stable-sort property not exercised Code comments "stable sort preferred so equal-key rows preserve SQL-side ordering" — no test places two rows with equal keys and asserts insertion-order preservation. A regression to Tests · 3:
Guidelines · NB-1:
Guidelines · NB-2: Other contributors running Docs · 1: All other entries use a one-liner. The flags and keymaps detail belongs in the planned Docs · 2: These two signals contradict each other. Either drop the allow or update the comment. INFO
Verdict: PASS — one BLOCKING bug fixed (TerminalGuard screen-wipe); remaining findings are NON-BLOCKING or INFO. Ready for merge at owner's discretion. |







Summary
S1 of the
/topinitiative — see.samo/spec/top/SPEC.md(v0.2) for the full eight-sprint roadmap. This PR ships the MVP: a live ratatui-powered Postgres monitor with a single Activity view overpg_stat_activity. Inspired bypg_topandpgcenter top; module layout mirrors/ashso reviewers can pattern-match./topinteractive TUI (alt-screen + mouse capture + raw-mode RAII guard)./top --onceheadless mode — one-shot text snapshot for CI / scripting / evidence.ratatui::TestBackend, theme, args parser).exec_commandnow dispatches/-extension commands. Previously only\-metas were recognized in-c/ stdin mode, so any rpg-extension command (/top,/dba,/ash, …) passed via-cfell through to SQL execution and raised a syntax error. Five-line addition mirroring the existing\branch and the interactive REPL.Out of scope (deferred to later sprints)
View switching · sort/filter · drill-down (Q/E/A/L/W) · blocking tree · sparklines · kill actions · pause/rewind · JSON output · AI hand-offs (
Shift-XeXplain selected,Shift-IInfo on whole view) ·rpg topclap subcommand alias · system-stats (cpu/mem/io/net) reader. All tracked in.samo/spec/top/SPEC.md§7.Red/green TDD
Manual integration testing against PG 17.9 caught one bug that unit tests didn't cover:
extract(epoch from …)returns Postgresnumeric, which tokio-postgres cannot deserialize directly intof64. The--oncesnapshot failed witherror deserializing column 9. Fix:elapsed_time_columns_cast_to_float8tosrc/top/sql.rsasserting the SQL casts::float8. Test failed.::float8to the twoextract(...)expressions forqtime_secsandxtime_secs. Test passed.Test plan
cargo fmt --check— cleanRUSTFLAGS="-D warnings" cargo clippy --all-targets --all-features -- -D warnings— clean (matches CI)cargo test— 2101 passed; 0 failed (35 new tests insrc/top/)rpg --command "/top --once"against PG 17.9 with threepg_sleep(60)victims (see evidence below)Manual-test evidence
Local PG 17.9 (Debian) on port 55433. Three short-lived victim sessions running
pg_sleep(60)(one wrapped inBEGIN; … COMMIT;):rpg --command "/top --once"output (exit 0):Click to expand — 130×30 plain-text snapshot
Verified:
db,user,pg 17.9,primary, uptime,as of T…, connection LED, session counts.(9 rows)caption.qtime desc(waitandxtimecolumns populated).checkpointer,bg writer,walwriter,autovacuum launcher,logical replication launcher) shown viabackend_typewith empty client info — correct.The interactive run was also smoke-tested locally:
/topenters the alt-screen, refreshes every 1 s, responds to↑/↓/PgUp/PgDn/Home/End, and exits cleanly onq/Esc/Ctrl-C(TerminalGuard restores the parent terminal even if the loop panics).Refs
.samo/spec/top/SPEC.mdv0.2.samo/spec/top/decisions.md🤖 Generated with Claude Code