pi-sysmon
bottom-style braille system monitor charts (CPU / memory / network / LLM token throughput) for the pi coding agent
Package details
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
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.
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
/procdirectly — no need for packages likesysteminformation/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=<1to also enable automatic falloff after a spike) - Automatically excludes loopback/container traffic —
lo/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 /Rcache 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 setPI_SYSMON_LABEL=boxto 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 exactusage, 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 owncacheRead / (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
.tsdirectly underextensions/as an extension; laying out multiple files flat would makebraille.tsget 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
linemode 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.
regularTUI 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 | lineinstead.
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 exactusageofmessage_end, byte-aligned with pi footer's↑↓Rconvention, so it can be checked directly against the bottom line); - Without a snapshot (non-Linux /
/procunreadable) 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(configDirdefaults to~/.pi/agent) holdsmode,placementand the global defaultenabled— 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/resumeof 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.jsonthat only carriesenabled: 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 onrestores 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::layoutdoesx += 1after 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:
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. SetPI_SYSMON_LABEL=boxto bring back bottom-style floating boxes.Floating-box show/hide thresholds (only effective in
box/bothmode). bottom uses thehidden_legend_constraintsset 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.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:
- A width miscalculation crashes pi outright. pi's renderer throws
uncaughtExceptionand exits when a row exceeds the terminal width. Custom components must truncate withtruncateToWidth()and must not useString.slice()(it counts bytes, including ANSI escapes). - 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.
- Flat multi-file installation makes pi fail to start. See the installation notes above.
yMax <= 0or data containingNaNproducesNaNcoordinates; a non-null assertion!masks the problem and crashes at runtime. All coordinates need finiteness checks.setStatusfrom 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'sx += 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
pi — setWidget'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.
