Guides · Terminal UI · Part 3 of 3

Build a terminal app: live dashboards

The to-do list only repaints when you press a key — perfect for an editor, wrong for a monitor. A dashboard has to redraw on a clock too, whether or not anyone's touching the keyboard. This last part shows the one small change that turns the same loop into a live, self-refreshing view — the shape a CLI or Docker process/resource manager takes.

1 · readKey, but with a deadline

The trick is the timeout form of readKey. readKey(ms) waits up to ms milliseconds for a keypress and returns "" if none arrived — so the loop wakes on a tick or a keystroke, whichever comes first, with no background thread and no async machinery:

import { readKey } from 'klain:tty'

let tick = 0
while (running) {
  const key: string = readKey(1500)         // wake on a key OR after ~1.5s
  if (key === 'q') {
    running = false
  } else {
    tick = tick + 1                          // '' means "just the tick"
    render(view(sample(), tick))
  }
}
leave()

An empty return just means “the timer fired” — you re-sample and repaint. A real key still comes through immediately, so q quits without waiting out the second. One loop, one thread, both a clock and a keyboard.

This is also the point to handle Ctrl-C yourself if you want a graceful exit: in raw mode it arrives as an ordinary key (character code 3), not a signal, so a key.charCodeAt(0) === 3 check can call leave() and quit cleanly.

2 · Sampling the system

Everything else is just where the numbers come from. CPU utilisation is the classic delta of busy-versus-idle jiffies between two samples of os.cpus(); memory comes from totalmem()/freemem(). Because we're a native binary these are real OS calls, not shims:

import { cpus, totalmem, freemem } from 'os'

// Aggregate { total, idle } jiffies across every core.
function sample(): { total: number; idle: number } {
  const cs = cpus()
  let total = 0, idle = 0
  for (let i = 0; i < cs.length; i++) {
    const t = cs[i].times
    total += t.user + t.nice + t.sys + t.idle + t.irq
    idle += t.idle
  }
  return { total, idle }
}

// Utilisation = 1 - (idle delta / total delta) between two samples.
const cur = sample()
const cpuFrac = 1 - (cur.idle - prev.idle) / (cur.total - prev.total)
prev = cur

The process table is just as direct: shell out to ps with process.execFileSync, parse the lines into rows, and sort them in-process. Feed the rows to a List — which scrolls to the selection on its own — and a process.kill(pid) behind a y/n confirm turns the monitor into a manager:

// Snapshot every process via ps, parsed and sorted in-process. BSD-style
// -axo works on both macOS and Linux; we sort ourselves to avoid a
// platform-specific --sort flag.
function listProcs(sort: string): Proc[] {
  const raw: string = process.execFileSync('ps', ['-axo', 'pid,pcpu,pmem,comm'])
  const out: Proc[] = []
  for (const line of raw.split('\n').slice(1)) {
    const p = line.trim().split(/\s+/)
    if (p.length < 4) continue
    out.push({ pid: parseInt(p[0], 10), cpu: parseFloat(p[1]),
               mem: parseFloat(p[2]), comm: p.slice(3).join(' ') })
  }
  out.sort((a, b) => (sort === 'mem' ? b.mem - a.mem : b.cpu - a.cpu))
  return out
}

// ...and killing the selection is one guarded call:
if (key === 'y') { try { process.kill(procs[cursor].pid) } catch (e) {} }

Feed the CPU/memory fractions into the same Progress and Text components from Part 1, advance the spinner's frame each tick, and you have a live process manager:

A terminal process manager with CPU/memory bars and a scrollable process table
klaintop.ts — a htop-lite process manager: CPU/MEM bars plus a scrollable, sortable ps table (PID/CPU/MEM/command) you can kill from, all redrawn each tick via readKey(1500).

Good to know

  • Text is width-aware through a standard wcwidth table — wide CJK and emoji glyphs take two columns and combining marks fold onto their base, so alignment holds. The one gap is multi-code-point emoji joined by ZWJ (flag/family sequences), which still render as their separate glyphs.
  • Style enum props (colours, border, flexDirection) are compile-time literals in this release; numeric props (sizes, padding, a Progress value) can be any expression.
  • Rendering is immediate-mode: the tree is rebuilt every frame and the cell-diff keeps the actual bytes written to the terminal minimal.
  • A List taller than its box scrolls to the selection and draws a scrollbar on its own; you only manage the selected index.

The gallery

Four complete apps, each its own apps/ directory — read the source alongside these three parts:

  • todo — the to-do list we built (list, input, progress, fs).
  • klaintop — this process manager (timeout loop, os, ps + kill).
  • files — a two-pane file browser (nested layout, fs/path).
  • menu — a minimal selectable menu, a good starting skeleton.