gander-bin 0.6.2

Executable entry point for gander.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
# gander

**English** | [简体中文]README.zh-CN.md

> A cross-platform interactive process viewer in Rust — `take a gander` at your system.

gander shows you what is running on your machine and what it is costing. It runs
on **Linux, macOS and FreeBSD**, keeps almost all of htop's keybindings (the two
that differ are [listed below](#keys-that-differ-from-htop)), and is built on a
different screen layout than htop — one that spends its space on the process list
instead of on horizontal bars.

## Install

**Prebuilt binaries** (macOS and Linux) are on the
[Releases page](https://github.com/pandasir/gander/releases). FreeBSD builds from
source — it needs no headers beyond what the base system ships:

```bash
curl -LO https://github.com/pandasir/gander/releases/latest/download/gander-0.6.2-aarch64-apple-darwin.tar.gz
tar -xzf gander-0.6.2-aarch64-apple-darwin.tar.gz
./gander-0.6.2-aarch64-apple-darwin/gander
```

Pick the archive for your platform (`aarch64`/`x86_64` Apple Silicon or Intel
macOS; `x86_64-unknown-linux-musl` is a static binary that runs on any distro).
Each archive has a `.sha256` checksum next to it.

**From source** — requires **Rust 1.89+**. Nothing else: no libcurses, no ncurses
development headers, no C compiler beyond what `rustc` already needs.

The floor is not decorative: `ratatui` 0.30 needs 1.88 and `uuid` needs 1.89, and
building on an older toolchain fails. CI builds against whatever `Cargo.toml`
declares, so this number cannot drift away from reality unnoticed.

**Homebrew** (macOS and Linux):

```bash
brew install pandasir/tap/gander
```

**crates.io** — requires Rust 1.89+:

```bash
cargo install gander-bin
```

The registry name is `gander-bin` because `gander` is already taken by an
unrelated crate; the installed command is still `gander`.

**From source**:

```bash
git clone https://github.com/pandasir/gander
cd gander
cargo install --locked --path crates/bin
```

That puts `gander` in `~/.cargo/bin`, so it is on your `PATH` if you installed
Rust with rustup.

Prefer to run it without installing:

```bash
cargo run --release              # the TUI
```

Arch Linux users can install from the AUR with `yay -S gander`.

## Usage

```bash
gander                                     # the interactive TUI
gander --delay 500                         # sample every 500 ms
gander -n 5 -d 1000                        # 5 text samples, one per second
gander --json --limit 10                   # one JSON sample, 10 processes
gander --sort PERCENT_MEM --limit 20       # top 20 by memory
gander --filter "python|node"              # only matching command lines
gander --import-htoprc ~/.config/htop/htoprc --save
```

Run `gander --help` for the full list; every keybinding is either htop's or
listed in [docs/KEYS.md](docs/KEYS.md).

## Keys that differ from htop

Two of htop's existing bindings mean something else here, so an htop user pressing
them gets a different action than expected:

| Key | htop | gander |
|---|---|---|
| `I` | invert the sort order | cycle the selected header meter's display mode |
| `+` / `-` | expand / collapse a subtree in tree view | faster / slower sampling |

`+`/`-` were only ambiguous in tree view; outside it htop does not bind them, so
the common case is unaffected. `I` is a real conflict — there is no invert-sort
key any more; use `F6` / `<` `>` / `.` to reach the order you want.

Genuinely new keys:

| Key | Action |
|---|---|
| `L` | switch layout: `panels` ⇄ `htop` |
| `I` | cycle the selected header meter's mode (text / bar / graph) |
| `Shift+Left` / `Shift+Right` | move the header meter cursor |

`H` is **not** new — it is htop's "hide user threads" — and it works on both
platforms, not just Linux.

## Why gander over htop

**The process list gets the vertical space.** htop draws its meters horizontally,
and its height grows with the core count: one row per core. gander puts the system
state in a vertical side column whose height barely changes, with per-core load in
a grid that fits 16 cores in two rows.

Measured on this 10-core machine in a 120x24 terminal, both freshly started and
captured through a real terminal emulator: htop shows **11** process rows, gander
shows **21** — nearly twice the list. The gap widens on machines with more cores,
because only htop's side of it grows.

**You never leave the list to inspect a process.** In htop, learning a process's
command line, its exact memory, or whether it is sleeping or running means opening
a modal screen (`w`, `e`, `l`) and losing your position, your tags and what you
were comparing against. gander keeps an inspector beside the list that updates as
you move the selection: the wrapped command line, the state spelled out in words,
and the exact figures the narrow columns round. The modal screens still exist for
what genuinely needs them — environment, open files, locks, backtrace.

**The sort order is visible.** The `CPU%` and `MEM%` columns carry a bar painted
behind the number, scaled against the largest value in the table rather than
against 100%. On a quiet machine where the top process sits at 4%, an absolute bar
would be invisible on every row — exactly when "which of these is biggest" matters.

**Nothing blocks the UI.** Sampling runs on a background thread and the renderer
reads a snapshot, so a slow scan never freezes the interface. This matters most
where htop struggles: reading `smaps_rollup` across thousands of PIDs.

**Scriptable output.** `--json`, `-n`, `--sort`, `--filter` and `--limit` make it
usable in monitoring and shell pipelines, which htop cannot do.

**Correct on macOS.** gander reads the full process table through
`sysctl(KERN_PROC_ALL)` rather than `proc_pidinfo`, which as a normal user sees
only about 60% of it. Run state comes from `proc_taskinfo`, because the `p_stat`
field on current Darwin reports "runnable" for a process blocked in `read(2)`.

**Host throughput in the header.** htop has per-process I/O rate columns, but no
host-wide network or disk meter. gander's `NetworkIO` and `DiskIO` scale their bars
against the largest rate seen so far rather than a fixed ceiling, so they are
legible on a 100 Mbit link and on a 10 Gbit one.

**It tells you *which* container.** htop's `CONTAINER` column flags membership;
gander shows `docker:1f2e3d4c5b6a` or `kubepods:<pod-uid>`. Detection combines the
cgroup path (which names the runtime and id) with the PID namespace (which is the
only reliable membership test — a container with its own cgroup namespace reports
`/` and would otherwise look like the host).

## The two layouts

gander ships two, and `L` switches between them. The choice persists.

**`panels` (default)** — the vertical system column, the process table with
value bars, and the persistent inspector:

```
  CPU    21.5%│ PID  CPU%  MEM%   RES  COMMAND             2032 iTerm2
  ████░░░░░░░░│ 2032  23.9  1.9   152M  /Applications/iTe…  /Applications/iTerm…
  10 cores    │ 24087 11.4  0.1   5.9M  gander              USER   panda
  ▂▂▂▂▃▂▁▁ ·  │ 6511   7.4  3.0   242M  Google Chrome       STATE  S sleeping
  MEM  5.5G/16G│ …                     …                    CPU    23.9%
  SWP  3.9G/5G│                                            MEM    1.9% 114M
  LOAD 2.69…  │                                            TIME   30:09.95
  UP     5h 3m│
  TASKS  552  │
  RUN      1  │
  THR   3370  │
```

The per-core grid paints one cell per core, colour-banded by load, and the
sparkline under it is aggregate CPU over time.

**`htop`** — the classic arrangement, fully supported. Set `layout = "htop"` in
the config to make it the default.

The reasoning, including why the bars are table-relative and what happens on a
narrow terminal, is in [docs/LAYOUT.md](docs/LAYOUT.md).

## Configuration

TOML at `$XDG_CONFIG_HOME/gander/config.toml` (or `$GANDER_CONFIG`). Every screen
tab keeps its own columns and sort, and the header meters and their display modes
are configurable too. Settings are written on a clean exit only, matching htop.

An existing htop setup can be imported:

```bash
gander --import-htoprc ~/.config/htop/htoprc --save
```

Search and filter history lives separately, at
`$XDG_STATE_HOME/gander/{search,filter}_history` — the config is a decision you
copy between machines, history is a byproduct of using this one. `Up` / `Down` in
the `F3` / `F4` prompt walks it, so re-running yesterday's filter is one key.

### Identity columns

Seven columns that describe *which* process this is rather than what it is doing.
All of them work on all three platforms.

| Column | Meaning |
|---|---|
| `SESSION` | Login session id (`SID`); `-` when the kernel will not say |
| `TTY` | Controlling terminal, decoded to `pts/3` or `tty1`; `?` when there is none |
| `TPGID` | The foreground job's process group on that terminal |
| `STARTTIME` | Wall-clock start, boot time + the process's start ticks |
| `TGID` | Thread group id: the process a thread row belongs to |
| `SCHED_POLICY` | `other`, `fifo`, `rr`, `batch`, `idle`, `deadline` |
| `PROC_COMM` | The kernel's short command name |
| `PROC_EXE` | Resolved executable path |
| `CWD` | Working directory |

`COMM` and `PROC_COMM` are not the same column, and that is not an accident: `COMM`
is htop's Command column and shows the *command line*, while `PROC_COMM` is the
executable's base name as the kernel holds it. htop makes the same distinction.

`STARTTIME` is shifted by the snapshot's boot time and rendered in **local** time,
so it agrees with the `Clock` in the header instead of silently being UTC.

`TTY` decodes the kernel's `tty_nr` device number rather than printing it: `34816`
is not a terminal anyone recognises, `pts/0` is. `TPGID` renders `-` for the
kernel's `-1`, which means "no foreground job" and is not a process group.

### Container columns (Linux)

`F2` → Columns offers two more on Linux:

- **`CONTAINER`** — the container a process is in, as `runtime:id`, e.g.
  `docker:1f2e3d4c5b6a`, `kubepods:1a2b3c4d-...`, `lxc:web-1`. `-` for a host
  process.
- **`CGROUP`** — the full cgroup path, for when the id is not enough.

Detection uses the cgroup path *and* the PID namespace, because neither is
sufficient: the path names the runtime and the id but reads `/` inside a container
that has its own cgroup namespace, and the namespace says "containerised" without
saying which. A process the namespace flags but the path cannot name shows
`container:?` rather than an invented id.

Press `O` to hide containerised processes and leave the host's own. On macOS there
is no container detection, so the key says so instead of toggling nothing.

### Header meters

`F2` → Header adds and reorders them; `I` cycles the selected meter's mode
(text, bar, graph) and the choice is saved per meter. Beyond the htop set (CPU,
memory, swap, load, uptime, tasks, clock, date, hostname, blank) there are two
throughput meters and four Linux-depth meters:

- **`NetworkIO`** — host receive/transmit rate, loopback excluded.
- **`DiskIO`** — host read/write rate from `/proc/diskstats`, whole disks only
  (partitions and `dm-*` are skipped so nothing is counted twice). macOS has no
  host-wide equivalent without IOKit, so the meter says so there instead of
  showing a permanent `0 B/s`.

Both are rates over cumulative counters, scaled against the largest rate seen so
far rather than a fixed ceiling — a bar pinned to 1 Gbit is an invisible sliver on
a 100 Mbit link. Unlike the container columns these work on macOS too, where
`DiskIO` reports itself unavailable rather than showing zero.

The four Linux-depth meters, all of which say `n/a here` where the subsystem is
absent rather than drawing a permanent empty bar:

- **`PSI`** — pressure stall information: `some` (at least one task waiting) and
  `full` (nothing could run) for CPU, memory, IO and IRQ. This is the one figure
  that separates "busy" from "struggling": 90 % CPU with an empty run queue is
  healthy, 30 % CPU with a 40 % `io full` average is not, and CPU% and load
  average both look fine in the second case.
- **`Zram`** — compressed swap in RAM, as `orig → compressed (ratio)`. A 4:1 ratio
  means 4 GB of swap occupying 1 GB of memory.
- **`Zswap`** — the compressed pool in front of the swap device, as a share of its
  ceiling (`max_pool_percent` × physical memory). This is what says whether the
  next swapped page goes to disk.
- **`HugePages`** — pre-reserved huge pages, `used/total`. Reserved pages are
  unavailable to everything else whether or not a process maps them.

### Linux memory and GPU columns

On Linux, `F2` → Columns also offers the PSS family and the GPU pair:

| Column | Meaning |
|---|---|
| `M_PSS` | Proportional set size: shared pages divided between their sharers |
| `M_EPSS` | PSS plus the swapped-out equivalent (htop calls this `M_PSSWP`) |
| `M_PRIVATE` | `Private_Clean + Private_Dirty`: what exits with the process |
| `M_SWAP` | Memory this process has pushed to swap |
| `GPU%` | Share of GPU time, from `drm-usage-stats` |
| `GPU_TIME` | Cumulative GPU time, summed over every engine class |
| `CPUD%` | Share of this task's life runnable but waiting for a CPU |
| `IOD%` | Share of this task's life waiting for synchronous block I/O |
| `SWPD%` | Share of this task's life waiting for a page to be swapped in |

**Why PSS rather than RES.** RSS counts a shared page once per process, so two
processes sharing 100 MB of libraries both report 100 MB and the `RES` column adds
up to 200 MB that does not exist. PSS divides each shared page by the number of
sharers, which is the only per-process figure that sums correctly across a table.

**Both families are read on demand.** `smaps_rollup` makes the kernel walk every
VMA of the process; `fdinfo` costs a directory walk and a read per descriptor.
Either one, run across a desktop's process table, costs more than the rest of the
scan together. So neither is read unless a column that needs it is actually on
screen — and `gander --sort M_PSS` pays for exactly what it asked for. A row whose
figures were not read shows `-`, never `0`: another user's `smaps_rollup` needs
`CAP_SYS_PTRACE`, and "not measured" is not "zero".

**No vendor SDK.** The GPU figures come from the kernel's own `drm-usage-stats`
keys, so `amdgpu`, `i915`/`xe` and `nouveau` work with nothing installed. A driver
that does not implement them shows dashes.

**The delay columns answer a different question.** `CPU%` says how much work a
process does; `IOD%` says how much of its life it spent unable to do any. A process
at 0.0 % CPU with 40 % `IOD%` is not idle, it is stuck — and nothing else in the
table distinguishes the two. They come from `TASKSTATS` over generic netlink,
speaking the protocol directly rather than linking `libnl3`.

They need `CAP_NET_ADMIN`, which an ordinary process does not have: unprivileged
`gander` gets `EPERM`, stops asking after the first refusal, and renders `-` in all
three columns. That is deliberate — `0.0%` would claim the process never waited, and
the figure was never obtained.

The percentages are **lifetime averages**, not interval rates: the kernel keeps
cumulative counters, so a stall an hour ago still shows. Read them as "how much of
this process's existence was spent waiting", not "what is it doing right now".

### Themes

Eight, chosen with `F2` → Colors (or `color_scheme` in the config):

| | |
|---|---|
| `gander` (the default) | teal on deep navy, purple accents |
| `default` | htop's own palette |
| `monochrome` | no colour at all |
| `midnight` | blue-on-blue, low contrast |
| `classic` | deep navy, one accent, solid blue selection |
| `verdant` | green-leaning |
| `paper` | light background, for a bright terminal |
| `quiet` | desaturated greys |

`paper` is the one that matters structurally: every style sets a foreground and
most leave the background unset, which is invisible on a dark theme and broken on a
light one. The renderer paints the theme background over the whole frame first, so
all four draw paths (htop, panels, setup, dialogs) inherit it.

## Platform support

| | Linux | macOS | FreeBSD |
|---|---|---|---|
| Process list | `/proc` | `sysctl(KERN_PROC_ALL)` + `libproc` | `sysctl(KERN_PROC_PROC)` |
| Per-process details | `/proc/<pid>` | Mach + `proc_pidinfo` | `kinfo_proc` |
| Container columns | cgroup + PID namespace | not available | not available |
| PSS / EPSS / private columns | `smaps_rollup`, read on demand | not available | not available |
| PSI pressure (some/full) | `/proc/pressure` | not available | not available |
| Zram / Zswap / HugePages meters | `/sys/block/zram*`, `meminfo` | not available | not available |
| GPU columns (`GPU%`, `GPU_TIME`) | `drm-usage-stats` in `fdinfo` | not available | not available |
| Delay columns (`CPUD%`, `IOD%`, `SWPD%`) | `TASKSTATS` netlink (needs `CAP_NET_ADMIN`) | not available | not available |
| Threads as rows | yes (`H`) | yes (`H`) | yes (`H`) |
| CPU affinity (`a`) | read/write | scheduler hint only, explains why | read/write (`cpuset`) |
| I/O priority (`i`) | read/write | not available, explains why | not available, explains why |
| Host disk throughput (`DiskIO`) | `/proc/diskstats` | not available | `devstat` |

All three backends are verified against a live OS — FreeBSD in a QEMU VM running
14.5-RELEASE, where the whole test suite runs inside the guest. Other unix targets
compile the `/proc` backend but have no live data source, and the TUI refuses to
start there rather than showing nothing. Details:
[docs/PORTABILITY.md](docs/PORTABILITY.md).

## Build and test

```bash
cargo build --release      # target/release/gander
cargo test                 # 374 on macOS, 363 on FreeBSD
cargo clippy --all-targets # zero warnings
cargo bench -p gander-core # scan timings
```

The test fixtures under `crates/core/tests/fixtures/proc/` are real `/proc`
snapshots, so the Linux read path is testable on any unix.

## Design

gander keeps htop's *feature list* as the requirements document and throws away
its *code structure*: htop is a retained-mode widget toolkit built on ncurses,
while gander is an immediate-mode ratatui front end over a UI-independent core.
The core crate has no UI dependency, which is what makes `--json` nearly free.

| | htop (C) | gander (Rust) |
|---|---|---|
| Render | ncurses, retained-mode panels | ratatui + crossterm, immediate-mode |
| Widgets | `Object` vtable, focus stack | `enum Screen` + per-screen `handle_key()` |
| Data | single-threaded scan | parallel scan (rayon) + background sampler |
| Config | `htoprc`, hand-rolled parser | TOML, plus an `htoprc` importer |
| Platforms | Linux, BSD, macOS | Linux, macOS, FreeBSD, same `PlatformSampler` trait |

More: [docs/DESIGN.md](docs/DESIGN.md) for the design,
[docs/ROADMAP.md](docs/ROADMAP.md) for what is done and what is next,
[CHANGELOG.md](CHANGELOG.md) for what changed in each release, and
[docs/RELEASING.md](docs/RELEASING.md) for how a release is cut.

## License

GPL-2.0-or-later, matching htop. Behaviour, keybindings and column semantics are
derived from htop; keep the license compatible and credit it.