---
sidebar_position: 1
title: proctop
---

# proctop

A `top(1)`-like process viewer. Every row is a live process; columns show PID, %CPU, resident memory, and the full command line.

![proctop](/img/examples/proctop.gif)

```sh
# live view, redraws every 1.5s
yeet run examples/proctop/render.js

# one snapshot to stdout (pipe-safe)
yeet run examples/proctop/render.js --once

# sort by memory instead of CPU
yeet run examples/proctop/render.js --sort mem

# raw event stream, one JSON object per line
yeet run examples/proctop/dump.js
```

## Source

[`examples/proctop`](https://github.com/yeet-src/yeet-scripts/tree/master/examples/proctop) on GitHub.

## How it works

The layout is driven by a single `COLUMNS` array in `render.js`. To add a field, append one entry there and fetch the data in `data.js`. Nothing else changes.

<details>
<summary>Source <a href="https://github.com/yeet-src/yeet-scripts/tree/master/examples/proctop" target="_blank" rel="noopener noreferrer" title="View on GitHub" className="details-gh-link" onClick={(e) => e.stopPropagation()}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58v-2.23c-3.34.73-4.03-1.42-4.03-1.42-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.08 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.49 1 .11-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 3-.4c1.02.005 2.05.138 3 .4 2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.82 1.1.82 2.22v3.29c0 .32.19.69.8.58C20.56 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg></a></summary>

**data.js**

```js
// proctop / data.js
//
// Subscribes to procs(), computes per-PID CPU% from utime+stime deltas,
// and emits a stream of ticks. Pure data layer — no tty, no style.
//
// Events:
//   { kind: 'config', t, ticksPerSec, intervalMs }
//   { kind: 'tick',   t, pids: [{ pid, comm, cmdline, cpuPct, rssBytes }] }
//   { kind: 'error',  t, error }
//
// To expose a new field to the renderer, fetch it in the subscription
// query below and add it to the per-pid object pushed into `pids`.

const unwrap = (resp) => resp.data ?? resp;

export function watch(opts, emit) {
  const intervalMs = opts.intervalMs ?? 1500;
  let stopped = false;
  let ticket = null;

  /* pid → { ticks, t } from the previous sample, for CPU% deltas. */
  const prev = new Map();
  let ticksPerSec = 100;

  yeet.graph
    .query("{ host { ticks_per_second } }")
    .then((resp) => {
      const v = resp.data?.host?.ticks_per_second;
      if (typeof v === "number" && v > 0) ticksPerSec = v;
      emit({ kind: "config", t: Date.now(), ticksPerSec, intervalMs });

      ticket = yeet.graph.subscribe(
        `subscription {
           procs(interval_ms: ${intervalMs}) {
             pid
             cmdline
             stat { comm utime stime rss_bytes }
           }
         }`,
        (resp) => {
          if (stopped) return;
          const list = unwrap(resp)?.procs;
          if (!Array.isArray(list)) return;

          const now = Date.now();
          const pids = [];
          const seen = new Set();

          for (const p of list) {
            const ticks = (p.stat?.utime ?? 0) + (p.stat?.stime ?? 0);
            seen.add(p.pid);

            const last = prev.get(p.pid);
            let cpuPct = 0;
            if (last) {
              const dTicks = ticks - last.ticks;
              const dt = (now - last.t) / 1000;
              if (dt > 0 && dTicks > 0) {
                cpuPct = (dTicks / ticksPerSec / dt) * 100;
              }
            }
            prev.set(p.pid, { ticks, t: now });

            pids.push({
              pid: p.pid,
              comm: p.stat?.comm ?? "?",
              cmdline: Array.isArray(p.cmdline) ? p.cmdline : [],
              cpuPct,
              rssBytes: p.stat?.rss_bytes ?? 0,
            });
          }

          for (const k of [...prev.keys()]) {
            if (!seen.has(k)) prev.delete(k);
          }

          emit({ kind: "tick", t: now, pids });
        },
      );
    })
    .catch((e) => {
      emit({ kind: "error", t: Date.now(), error: String(e?.message ?? e) });
    });

  return {
    stop() {
      stopped = true;
      if (ticket !== null) yeet.graph.unsubscribe(ticket).catch(() => {});
    },
  };
}
```

**render.js**

```js
// proctop / render.js
//
// A small, top(1)-like process viewer. The whole layout is driven by the
// COLUMNS array below — to add a field, append one entry. Nothing else
// in this file needs to change.
//
// Modes:
//   --once       print one snapshot and exit (pipe-safe; no PTY needed)
//   (default)    live: alt-screen, redraw on each tick
//
// Flags:
//   --interval 1500   poll cadence (ms)
//   --rows N          max process rows to show (default: fit terminal)
//   --sort cpu|mem|pid   sort key (default: cpu)

import { watch } from "./data.js";

const intervalMs = Number(yeet.args.interval) || 1500;
const once = yeet.args.once === true;
const rowOverride = Number(yeet.args.rows) || null;
const sortKey = yeet.args.sort || "cpu";

/* ------------------------------------------------------------------ */
/* COLUMNS — the single extension point.                              */
/*                                                                     */
/* Each entry describes one column:                                    */
/*   header   text shown in the header row                             */
/*   width    visible character width (COMMAND uses the leftover)      */
/*   align    'left' | 'right'                                         */
/*   get(p)   returns the cell string (already formatted)              */
/*   color?(p, str)   optional style wrapper for the cell              */
/*                                                                     */
/* To add a column: append an entry, fetch the field in data.js, and   */
/* you're done.                                                         */
/* ------------------------------------------------------------------ */

const COLUMNS = [
  {
    header: "PID",
    width: 7,
    align: "right",
    get: (p) => String(p.pid),
    color: (_, s) => style.dim(s),
  },
  {
    header: "%CPU",
    width: 6,
    align: "right",
    get: (p) => p.cpuPct.toFixed(1),
    color: (p, s) =>
      p.cpuPct >= 50 ? style.red(s) : p.cpuPct >= 10 ? style.yellow(s) : s,
  },
  {
    header: "RES",
    width: 8,
    align: "right",
    get: (p) => fmtBytes(p.rssBytes),
  },
  {
    header: "COMMAND",
    width: 0, /* 0 = take remaining width */
    align: "left",
    get: (p) =>
      p.cmdline.length > 0
        ? p.cmdline.join(" ").replace(/(?:\\\s|\s)+/g, " ").trim()
        : `[${p.comm}]`,
    color: (p, s) => (p.cmdline.length === 0 ? style.dim(s) : s),
  },
];

/* ------------------------------------------------------------------ */
/* Sorting                                                             */
/* ------------------------------------------------------------------ */

const SORTS = {
  cpu: (a, b) => b.cpuPct - a.cpuPct,
  mem: (a, b) => b.rssBytes - a.rssBytes,
  pid: (a, b) => a.pid - b.pid,
};

/* ------------------------------------------------------------------ */
/* Formatting helpers                                                  */
/* ------------------------------------------------------------------ */

function fmtBytes(n) {
  if (!n) return "0";
  if (n >= 1024 * 1024 * 1024) return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`;
  if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
  if (n >= 1024) return `${(n / 1024).toFixed(0)}K`;
  return `${n}B`;
}

function pad(s, n, align) {
  if (s.length >= n) return s.slice(0, n);
  const fill = " ".repeat(n - s.length);
  return align === "right" ? fill + s : s + fill;
}

/* ------------------------------------------------------------------ */
/* Layout                                                              */
/* ------------------------------------------------------------------ */

function termSize() {
  try {
    return tty.size();
  } catch {
    return { rows: 24, cols: 100 };
  }
}

function resolveColumnWidths(totalCols) {
  /* Fixed columns + single spaces between them; remainder goes to the
   * lone width:0 (flex) column. */
  const gaps = COLUMNS.length - 1;
  let fixed = gaps;
  for (const c of COLUMNS) if (c.width > 0) fixed += c.width;
  const flex = Math.max(10, totalCols - fixed);
  return COLUMNS.map((c) => (c.width > 0 ? c.width : flex));
}

function renderHeader(widths) {
  const cells = COLUMNS.map((c, i) => pad(c.header, widths[i], c.align));
  return style.bold(style.reversed(cells.join(" ")));
}

function renderRow(p, widths) {
  const cells = COLUMNS.map((c, i) => {
    const raw = c.get(p);
    const padded = pad(raw, widths[i], c.align);
    return c.color ? c.color(p, padded) : padded;
  });
  return cells.join(" ");
}

function summary(pids) {
  const total = pids.length;
  const running = pids.filter((p) => p.cpuPct > 0).length;
  const totCpu = pids.reduce((s, p) => s + p.cpuPct, 0);
  const totMem = pids.reduce((s, p) => s + p.rssBytes, 0);
  return (
    `${style.bold("proctop")}  ` +
    style.dim(
      `${total} procs · ${running} active · ` +
        `${totCpu.toFixed(1)}% cpu · ${fmtBytes(totMem)} rss · ` +
        `sort=${sortKey} · ${new Date().toISOString().slice(11, 19)}`,
    )
  );
}

function renderFrame(pids, size) {
  const widths = resolveColumnWidths(size.cols);
  const sortFn = SORTS[sortKey] ?? SORTS.cpu;
  const sorted = pids.slice().sort(sortFn);
  const maxRows = rowOverride ?? Math.max(5, size.rows - 3);

  const out = [summary(pids), renderHeader(widths)];
  for (const p of sorted.slice(0, maxRows)) out.push(renderRow(p, widths));
  return out;
}

/* ------------------------------------------------------------------ */
/* Entry                                                               */
/* ------------------------------------------------------------------ */

if (once) {
  /* Wait for one tick, print, exit. */
  const handle = watch({ intervalMs }, (ev) => {
    if (ev.kind === "error") {
      console.error(`proctop: ${ev.error}`);
      handle.stop();
      yeet.exit();
      return;
    }
    if (ev.kind !== "tick") return;
    for (const line of renderFrame(ev.pids, termSize())) console.log(line);
    handle.stop();
    yeet.exit();
  });
} else {
  let hasTty = true;
  try { tty.write(""); } catch { hasTty = false; }
  if (!hasTty) {
    console.error("proctop: live mode needs a PTY. Use --once or dump.js.");
    yeet.exit();
  }

  tty.alt();
  tty.hideCursor();
  tty.clear();

  const CURSOR_HOME = "\x1b[H";
  const ERASE_TO_EOL = "\x1b[K";
  const ERASE_TO_EOS = "\x1b[J";
  let lastLineCount = 0;

  function paint(lines) {
    let buf = CURSOR_HOME;
    for (const line of lines) buf += line + ERASE_TO_EOL + "\n";
    if (lines.length < lastLineCount) buf += ERASE_TO_EOS;
    tty.write(buf);
    lastLineCount = lines.length;
  }

  watch({ intervalMs }, (ev) => {
    if (ev.kind === "error") {
      paint([style.red(`error: ${ev.error}`)]);
      return;
    }
    if (ev.kind !== "tick") return;
    paint(renderFrame(ev.pids, termSize()));
  });
}
```

</details>
