pi-sysmon

bottom-style braille system monitor charts (CPU / memory / network / LLM token throughput) for the pi coding agent

Packages

Package details

extension

Install pi-sysmon from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-sysmon
Package
pi-sysmon
Version
0.5.0
Published
Sep 22, 2026
Downloads
171/mo · 171/wk
Author
zzjcool
License
MIT
Types
extension
Size
457.6 KB
Dependencies
0 dependencies · 2 peers
Pi manifest JSON
{
  "image": "https://raw.githubusercontent.com/zzjcool/pi-sysmon/main/docs/images/overview.png",
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-sysmon

bottom-style braille line charts inside pi

CPU · Memory · Network · Tokens — real-time history curves drawn with braille dot-matrix characters

test license

The default four charts at 150 columns wide. Curves are drawn with braille characters, y-axis tick marks are overlaid inside the plot area, and time labels are embedded in the bottom border.

English | 简体中文 | X @zzjcoo

Features

  • Real line charts, not progress bars or sparklines — each character encodes 2×4 braille dot sub-pixels, effectively multiplying terminal resolution by 8
  • Zero dependencies — only Node built-ins plus pi's public extension API
  • Reads /proc directly — no need for packages like systeminformation / pidusage
  • Four charts by default — CPU / Memory / Network / Tokens (LLM throughput rate); the Tokens chart shows, in real time, the output-token rate between this pi process and the model API, and cross-checks against the ⚡ 23.0 t/s (avg) that pi itself shows in the status bar
  • Proper axes — y-axis tick marks, an x-axis line, and time-window labels, replicating bottom's layout
  • Top of y-axis = true window maximum — the height at any point in the 60s window can be read directly off the top tick; the scale never lies (set PI_SYSMON_SCALE_WINDOW=<1 to also enable automatic falloff after a spike)
  • Automatically excludes loopback/container trafficlo / veth* / docker* / br-* don't count toward network speed (measured: while pushing 30 MB/s of loopback traffic locally, the network chart reports only the physical NIC's 178 KB/s)
  • Responsive side-by-side layout — four charts in a row on wide terminals, automatically degrading to 2×2 / 1-column stacking as it narrows, so curves never get crushed into noise
  • Tokens chart shows both directions — the readings use the same convention as pi's own status bar ( upstream input / downstream output / R cache reads), so the numbers can be compared directly; the upstream figure is an exact value, while the rate is an estimate (marked with ~)
  • High plot-area efficiency — tick marks overlay the plot area and time labels sit inside the border. At the same block height, the plot area is ~71% larger than the old layout where ticks owned a column and the axis owned a row (50 columns wide, 8 rows tall: 168 cells → 288 cells)
  • Readings live in the border title bar (default) — reusing the fill that line already has: zero cost, no curve occlusion; or set PI_SYSMON_LABEL=box to bring back bottom's top-right floating box
  • Persistent state — display preferences (mode / placement) and the global on/off default are remembered in a config file across restarts; the per-session on/off lives in the session itself
  • Multiple display modes — chart (default) / single text line / full footer; in fullscreen TUI mode a clickable chip switches chart ⇄ line with one click
  • Linux-friendly, doesn't crash elsewhere — collection degrades to zero values on non-Linux platforms

Screenshots

Each chart's readings are written into the border title bar (reusing the fill that line already has, so no curve is covered). Y-axis tick marks are overlaid inside the plot area, and time labels are embedded in the bottom border.

The space these two choices save is quantifiable. Comparing at the same block height (8 rows) and block width (50 columns):

Layout Chrome per block Plot area
Old (ticks own 5 columns + axis owns 1 row) 4 rows 42 × 4 = 168
Now (overlay + border doubles as axis) 2 rows 48 × 6 = 288

The gain factors into two multipliers: width 42 → 48 (+14%, ticks no longer own a column) × rows 4 → 6 (+50%, the two saved rows go back to data) ≈ +71%.

Automatic degradation on narrow terminals (95 columns)

When the terminal narrows, the charts go 2×2, and narrower still, 1 column stacked. The breakpoint is 96 columns: ≥ 96 gives four charts in a row (the screenshot above), < 96 drops to 2×2 (this one is 95 columns). At the same width, bottom would crush all four charts into one row (about 16 columns each), making the curves unreadable.

The two directions of the Tokens chart

┌ Tokens ─ ~58t/s ·92% ⌀87%  ↑5.9k ↓60 R2.7k ─┐

The readings use the same convention as pi's own status bar ( upstream input / downstream output / R cache reads), so the two can be compared directly. A few details:

  • The main curve only plots the downstream rate. The two directions have completely different shapes over time (upstream is one bulk upload; downstream streams token by token — a measured ratio of about 516:1), so plotting them on the same axis would flatten downstream to 0.2% of the height.
  • ~ only appears on the rate — it's estimated from streaming deltas; the cumulative figures come from the provider's exact usage, so they carry no ~. Which number to trust is obvious at a glance.
  • As the block narrows, segments are dropped by importance: rate → instantaneous hit → cumulative hit → upstream → downstream → cache reads.

Cache hit rate: the second curve

Cached prompt tokens are billed far cheaper than fresh input, so the hit rate is the one number that says whether the cache is actually working. The Tokens block shows it two ways:

  • ⌀N% — session-cumulative hit rate (the yellow curve). reads as "average"; the value is recomputed from the session's running totals every time a message lands, so the curve is a staircase — honest, since the underlying numbers are exact provider-reported totals with no sub-frame information to draw.
  • ·N% — instantaneous hit rate (title bar only). The last turn's own cacheRead / (cacheRead + input) ratio; this is the actionable one; the cumulative average would hide a cache-missing turn for a long time.

The yellow curve has its own fixed 0–100% scale, pre-mapped onto the TPS axis and excluded from the y-scale: a 90% hit rate always renders as 90% of the plot height, even while a 3000 t/s streaming spike pushes the TPS axis top higher. The gutter still shows TPS only — the color binding (yellow curve ⇄ the readout) is what tells the two apart.

Both readouts appear only once the provider has reported a cache read (R); sessions that never touch the cache degrade to the previous single-curve block unchanged. In line mode the same ·N%/⌀N% readouts appear (gated identically), and the token group now sits before NET in the importance order — when the line runs out of room, whole groups are dropped from the tail, and the token readout is far less recoverable from elsewhere on screen.

Installation

Install with pi (recommended)

# latest release from npm
pi install npm:pi-sysmon

# or pin an exact version
pi install npm:pi-sysmon@0.4.0

# or straight from git
pi install git:github.com/zzjcool/pi-sysmon

Restart pi and the curves appear — enabled by default. Update later with pi update npm:pi-sysmon, remove with pi remove npm:pi-sysmon.

From a git clone: single file (simplest manual install)

git clone https://github.com/zzjcool/pi-sysmon && cd pi-sysmon && npm install
npm run build:single    # produces dist/pi-sysmon.ts
cp dist/pi-sysmon.ts ~/.pi/agent/extensions/pi-sysmon.ts

From a git clone: directory form (multiple files)

git clone https://github.com/zzjcool/pi-sysmon
cp -r pi-sysmon ~/.pi/agent/extensions/pi-sysmon

⚠️ The directory form must live in a subdirectory. pi's auto-discovery treats every .ts directly under extensions/ as an extension; laying out multiple files flat would make braille.ts get loaded as an extension and break the whole load.

Try it without installing

pi -e npm:pi-sysmon

Usage

/sysmon                     toggle on / off (**this session only**)
/sysmon on | off            explicit on / off (this session only)
/sysmon global on | off     default on/off for **future** sessions (also applies to this one)
/sysmon chart               chart mode (default)
/sysmon below|above         place the charts / line below (default) / above the editor (persisted)
/sysmon line                single-line text mode (the `CPU … TOK …` line; alias: `status`)
/sysmon footer              replace the entire footer with the charts (can be taller than widget mode)

chart / line are mutually exclusive display modes (naming a mode switches to it and turns the monitor on — it never turns the monitor off); on / off / above / below are orthogonal to the mode.

Click to switch modes (fullscreen TUI only)

In pi's fullscreen TUI mode (--tui-mode fullscreen, or TUI mode in /settings), the panel shows a small clickable [line] / [chart] chip at its bottom-right — one click switches between chart and line mode, no command needed:

┌ CPU ─ 12% ───────────────────────┐
│ ⡿⢸⣿⡇ ...                       │
└ 60s ──────────────────────── 0s ┘
                              [line]
  • The click runs the same path as /sysmon chart / /sysmon line — the mode switch is persisted as a global display preference, and the session's on/off state is never touched.
  • In line mode the chip shares the text row (the row stays exactly 1 line tall — the chip borrows its columns from the metrics, which drop whole groups from the tail as usual).
  • In fullscreen the chart pays for the chip row out of its row budget, so the panel never grows past WIDGET_MAX_ROWS.
  • Clicks outside the chip are not consumed: drag-to-select text over the panel keeps working exactly as before.
  • regular TUI mode (the default) never captures mouse input — the terminal owns the scrollback there — so no chip is rendered and the panel behaves exactly as before. Use /sysmon chart | line instead.

Why no hover highlight on the chip: under tmux/zellij/screen pi only enables button-motion mouse reporting (no move events), so the chip must read as clickable without any hover feedback.

Session scope vs global scope

/sysmon on|off is session-scoped: it is stored in that session's own entry list, so /resume brings the switch back exactly as you left it, while other sessions and future runs are unaffected. Off in one project stays off in that session only.

/sysmon global on|off writes the default for every new session to <configDir>/pi-sysmon.json, and applies it to the current session immediately (an "off by default from now on" that left the charts running would read as a broken command).

Precedence on startup: session choice → --sysmon → global default → built-in default (on). Mode (chart/line/footer) and placement (above/below) are global display preferences — they keep persisting in the config file, so you don't re-select your charts every session.

What line mode shows

One line, in descending order of importance; when it doesn't fit, whole segments are dropped from the tail (never cutting a number in half, e.g. no ↑1.0 stubs):

CPU 12%  MEM 60% 37G  NET ↑592K/s ↓34K/s  TOK ~0t/s ↑5.7k ↓89 R2.7k
└─ system metrics ──────────────────────┘ └─ LLM tokens ────────────────┘
  • CPU / MEM / NET come from the same sources and use the same colors as the corresponding chart-mode blocks;
  • TOK is LLM token throughput: ~<rate> (estimated from streaming deltas, hence the ~) plus the session-cumulative ↑input ↓output RcacheRead (from the exact usage of message_end, byte-aligned with pi footer's ↑↓R convention, so it can be checked directly against the bottom line);
  • Without a snapshot (non-Linux / /proc unreadable) the TOK segment is still emitted — it's the only metric that doesn't depend on /proc.

line is implemented as a widget rather than setStatus, so it follows above/below just like the charts: after /sysmon below, the charts and the line appear at the same position. (setStatus content is always rendered by pi's built-in footer, pinned at the bottom — that can't be changed.)

The charts lay out responsively by terminal width (using the default four charts as an example):

Terminal width Layout
≥ 96 columns 4 charts side by side in one row (8 rows total)
48 – 95 columns 2 columns × 2 rows
< 48 columns 1 column stacked

Row counts assume the default widget-mode budget (PI_SYSMON_CHART_HEIGHT=6, WIDGET_MAX_ROWS=18); footer mode has a larger budget (40 rows), so it gets more rows.

Each chart needs at least 24 columns (2 for the border + 22 for the plot). Tick marks are overlaid on the plot area rather than owning a column, so this floor is much lower than the old layout (where ticks owned 5 columns). Below that, bottom would crush N charts together (at 50 columns, 16 columns each — the curves are already unreadable), while this project degrades the arrangement instead.

The column count also adapts to the number of blocks (controlled by PI_SYSMON_TOKENS / PI_SYSMON_DISKS): with three charts, ≥ 72 columns already goes side by side (unchanged old behavior); with ≥ 4 blocks, the 3-column tier is skipped, avoiding a "3+1" shape where the second group has a single block plus a full row of blanks.

Configuration

Environment variable Default Description
PI_SYSMON_INTERVAL 1000 Sampling interval (milliseconds, floor 500)
PI_SYSMON_POINTS 60 History point count (overrides the count derived from PI_SYSMON_WINDOW)
PI_SYSMON_CHART_HEIGHT 6 Plot rows per chart (excluding the 2 border rows)
PI_SYSMON_LABEL title Reading placement: title (border title bar) / box (top-right floating box) / both / none
PI_SYSMON_WINDOW 60 Horizontal time-window length (seconds)
PI_SYSMON_SCALE_WINDOW 1 (= scale window == display window) Scale sampling ratio for rate charts: 1 = top of y-axis is the true window maximum; set <1 (e.g. 1/6) to enable "auto-falloff about 10s after a spike passes" (spikes beyond the scale then show as + markers)
PI_SYSMON_MODE chart Initial mode (chart / line / footer)
PI_SYSMON_PLACEMENT belowEditor Whether chart / line hang below (default) or above the editor: belowEditor / aboveEditor (also switchable anytime via /sysmon below / /sysmon above, persisted; footer mode is unaffected)
PI_SYSMON_TOKENS on LLM token throughput chart. Set 0 to return to the old three-chart form
PI_SYSMON_DISKS Set 1 to add a disk I/O chart (the 5th block)

On/off has two scopes, so it is stored twice:

  • <configDir>/pi-sysmon.json (configDir defaults to ~/.pi/agent) holds mode, placement and the global default enabled — written by /sysmon global on|off;
  • the current session's on/off choice is a session entry, written by /sysmon on|off, so it survives /resume of that session without touching any other session.

The --sysmon CLI flag forces the monitor on for that run only — it overrides the global default but not an in-session /sysmon off, since an explicit "off" typed inside the session is the more specific statement.

Config compatibility: an older pi-sysmon.json that only carries enabled: false (the pre-session-scope form) is read as "the global default is off", so sessions that never touched the switch start off — the behavior the old flag expressed. /sysmon global on restores the on-by-default feel.

What gets published

pi-sysmon is a pi package: it declares its extension in package.json under the pi key, needs nothing but the pi runtime at run time, and has no third-party dependencies (the pi packages are optional peerDependencies that pi itself provides). The npm tarball ships src/, docs/, both READMEs, the changelog and the license — it is what pi install npm:pi-sysmon fetches.

Implementation Notes

The charts use no TUI drawing library; the characters are composed by hand. The core is the braille dot matrix:

Each braille character (U+2800–U+28FF) maps to 8 independently lit dots, arranged in 2 columns × 4 rows:

(0,0) (1,0)      bit0  bit3
(0,1) (1,1)  →   bit1  bit4
(0,2) (1,2)      bit2  bit5
(0,3) (1,3)      bit6  bit7

So a character region of width × height actually resolves to 2*width × 4*height. Linearly mapping data points into sub-pixel coordinates and connecting them with Bresenham yields smooth polylines.

This matches what bottom does (ratatui's Marker::Braille). We align with bottom on these parameters:

Item bottom This project
Drawing characters Marker::Braille (2×4 sub-pixels) Same
Line algorithm Bresenham Same
Percentage-chart y-axis Fixed 0 .. 100.5 Same
Dynamic-chart y-axis Window maximum × 1.5 (headroom) Same
Grid lines None; only axis line + y ticks + time labels at both ends Same
Default sampling interval 1000 ms Same
Per chart Block border, title embedded in the top border Same
Y-tick position Own column (5 columns wide) Overlaid inside the plot area (no column)
Y-tick count 2 for percentages, 4 for rates Only the top 1 (with units)
X time labels Own a row Embedded in the bottom border (saves 1 row)
X axis line Owns a row Doubled by the 0 baseline (saves 1 row)
Top-right reading box Painted over the plot's top-right; disappears entirely if space is short Same (different threshold algorithm, see below)
When width runs out Crushes N charts into one row (16 columns each at 50 columns) Degrades to 2 / 1 columns
Default horizontal window 60 s Same
Auto-scale falloff Drops a tier after a lag counter (net_auto) No falloff by default (top = window maximum); optionally enable with PI_SYSMON_SCALE_WINDOW=1/6

Architecture

src/
├── metrics.ts      # collection layer: reads /proc/{stat,meminfo,net/dev,diskstats} + os.loadavg
├── state.ts        # decision layer: session-vs-global on/off precedence + `/sysmon` argument parsing (pure functions)
├── braille.ts      # rendering layer: data → braille dot matrix → character rows (pure functions, no side effects)
├── tokens.ts       # estimation layer: LLM streaming deltas → token counts (pure functions + closure meter)
├── blocks.ts       # assembly layer: history + snapshot → MetricBlock[] (pure data → pure data)
├── chart-panel.ts  # layout layer: responsive columns, borders, ticks, floating reading box, side-by-side joining
└── index.ts        # extension layer: pi lifecycle, commands, config persistence, timed refresh

Dependencies flow one way: index → {chart-panel, blocks, metrics}, blocks → chart-panel, chart-panel → braille, blocks → tokens. braille.ts and tokens.ts depend on no other module in this project.

Layering principles: braille.ts is pure functions (numeric arrays in, string arrays out), so it can be tested and reused without pi; metrics.ts only reads numbers and doesn't care how they're displayed.

How the layout was aligned with bottom

Not by eyeballing parameters, but by reading bottom/ratatui source to copy the algorithm exactly, then verifying character by character against frames captured from a real btm at controlled widths (the ┌ CPU ─ 1.91 1.80 2.17 ───┐ kind). For example:

  • The x-axis line does not extend into the y-axis column — ratatui's Chart::layout does x += 1 after placing the y-axis;
  • The last digit of the bottom-left time label lands on the y-axis column — with labels_alignment = Left, the first x-label's region is [chart_left, graph_left) (left-inclusive, right-exclusive), right-aligned;
  • Y-tick positions use dy = i * (plotH - 1) / (n - 1) (index 0 at the bottom).

Three intentional deviations:

  1. Readings default to the border title bar (PI_SYSMON_LABEL=title) instead of bottom's top-right floating box. The title-bar line already has to carry the block name, and the remaining fill is pure decoration — putting readings there costs nothing and covers no curve. bottom's floating box is painted over the plot's top-right and genuinely eats plot area. Set PI_SYSMON_LABEL=box to bring back bottom-style floating boxes.

  2. Floating-box show/hide thresholds (only effective in box/both mode). bottom uses the hidden_legend_constraints set of proportional thresholds (Network is 9/10 × 3/4), but those were calibrated for charts 40+ columns wide; applied to this project's 20~30-column blocks, the reading box would never show. This project instead requires "it fits (legendW <= plotW) and at least one curve row remains below the box (legendH < rows)" — the latter avoids the box's bottom border fusing with the plot's 0% baseline into a double line.

  3. Readings are tiered segments, dropped from the tail as the block narrows. For example, Network's order is instantaneous rate → cumulative traffic; on narrow blocks the cumulative figure is dropped first, preserving the more important instantaneous rate.

Testing

npm test          # 167 unit tests (braille 13 + layout 74 + tokens 31 + state 26 + extension 23)
npm run typecheck # tsc strict
npm run check     # runs both

test/braille.test.ts covers the braille bit mapping (verified dot by dot against the Unicode standard), coordinate mapping, output dimensions, and boundary inputs (empty data / all zeros / single point / NaN / Infinity / extremely small widths).

test/layout.test.ts covers the responsive layout (column breakpoints, column widths always summing exactly to the total, constant row count) plus two hard constraints that would crash or misalign pi: across the full width range 8..220 columns × multiple heights and block counts, it asserts no row's visible width overflows and the row count matches the layout's claim — these sweep the whole width range rather than spot-checking a few widths.

line mode is swept across the full width range here too: plainLineSegs + renderStyledLine must render at exactly the declared width across 8..220 columns (one column over and pi exits), must not throw even when squeezed to 1 column, and the CPU segment is always kept.

test/tokens.test.ts covers token estimation (English chars/4, per-character CJK, emoji count as one, non-string defenses), the drain semantics of per-second buckets (backlog during an off period must not turn into a fake spike), and the width upper bounds of fmtTps / tokenAxis (title-bar readings that overflow would make pi exit).

test/state.test.ts covers the on/off precedence chain (session choice → --sysmon → global default → built-in on), the backwards session-entry scan, and /sysmon argument parsing (globally must not be read as the global subcommand; unknown input must be rejected rather than silently toggling).

test/extension.test.ts drives the real index.ts against a stubbed pi API and a throwaway config dir. It locks down the scope rules that motivated this design: /sysmon off must not touch the config file, /sysmon global off must write the default and apply it to the current session, a new session must inherit the default while /resume restores the session's own choice, and headless /sysmon global off must still persist both.

Verification method: real-machine frame capture is the only truth

Unit tests catch geometry and bad values, but not packaging/loading problems — e.g. the extension never actually loading in a real pi, or a bundle with leftover relative imports. This project launches a real pi under pty and replays the screen with pyte to verify; this step has caught multiple "all unit tests green but nothing shows on a real machine" incidents.

Pitfalls Hit During Development

These were actually hit and fixed; recorded here to avoid repeats:

  1. A width miscalculation crashes pi outright. pi's renderer throws uncaughtException and exits when a row exceeds the terminal width. Custom components must truncate with truncateToWidth() and must not use String.slice() (it counts bytes, including ANSI escapes).
  2. Panel height changes shift the editor and break mouse selections. If a component returns different row counts with vs. without data, the editor jumps up and down, causing "selected text can't be copied". So height must be constant — occupy the same number of rows even with no data.
  3. Flat multi-file installation makes pi fail to start. See the installation notes above.
  4. yMax <= 0 or data containing NaN produces NaN coordinates; a non-null assertion ! masks the problem and crashes at runtime. All coordinates need finiteness checks.
  5. setStatus from different extensions shares the same line, and they squeeze/truncate each other on narrow terminals.

Contributing

Issues and PRs are welcome. Run npm run check to make sure tests and type checking pass.

You can also reach me on X: @zzjcoo

Acknowledgements

This project is inspired by bottom (btm).

The original idea of pi-sysmon was simply "bring btm's in-terminal braille line charts into pi". More than a visual homage — this project treats bottom as a behavioral baseline: reading its source, capturing its frames at controlled widths, and verifying the layout character by character:

  • Braille dot matrix (Marker::Braille, 2×4 sub-pixels) and Bresenham line drawing
  • Percentage charts fixed at 0 .. 100.5; dynamic charts take the window maximum × 1.5 headroom
  • Y-tick positions dy = i * (plotH - 1) / (n - 1) (index 0 at the bottom)
  • The x-axis line not extending into the y-axis column (ratatui Chart::layout's x += 1)
  • The last digit of the bottom-left time label landing on the y-axis column (labels_alignment = Left's half-open interval, right-aligned)

And precisely because it's a mature tool, we could see where not to copy it — e.g. this project puts readings in the border title bar (bottom paints a top-right floating box that eats plot area), and degrades the arrangement when width runs out instead of crushing N charts into 16 columns. The reasons for each deviation are recorded above in "How the layout was aligned with bottom".

Thanks to Clement Tsang and bottom's contributors.

Another prerequisite of this project is the extension API provided by pisetWidget's placement, message_update's streaming events, Theme colors; without these there would be no extension.

License

MIT — use it however you like.

This project borrows inspiration and layout algorithms from bottom (MIT licensed) but copies none of its code: everything was reimplemented from its observable behavior.