Update(procmon-plugin): Add keyboard navigation and reduce CPU sampling (#321)

* Add procmon plugin with live process panel

New Noctalia plugin: bar widget shows CPU/RAM usage and process
count; floating panel has a sortable, searchable process table
with per-process kill. Background service samples ps and /proc
stats and publishes via plugin state.

* Throttle data-driven panel renders

Rebuild table at most every 500ms to stay within the panel update
CPU budget. User interactions still render immediately; only state
watches and the tick go through maybeRender().

* Update procmon deps and fix timestamp precision

Document head, grep, and cat as required commands. Correct refresh
interval default to 1000 ms. Sample refreshedAtMs in milliseconds.

* Add keyboard navigation and reduce CPU sampling

- Panel: arrow keys move cursor, Ctrl+D kills selected, Ctrl+F focuses
  filter
- Service: sample /proc stats every 1s, ps every 2s; 2s render floor
- Widget: handle vertical bars, truncate text, use valid theme color
- Bump version to 0.4.0, plugin_api 13
This commit is contained in:
weinguyen
2026-08-09 13:55:01 -04:00
committed by GitHub
parent 82f1cffd55
commit 44e32f58bc
6 changed files with 274 additions and 111 deletions
+5 -5
View File
@@ -25,14 +25,12 @@ usage (plus the process count). Click the widget to toggle the process panel:
```sh
noctalia msg panel-toggle weinguyen/procmon:panel
```
The panel shows CPU, RAM and swap bars with the 1/5/15-minute load averages,
then a process table. Sort by the column dropdown (PID, CPU%, MEM%, RSS,
COMMAND) and flip asc/desc with the arrow button. Type in the filter box to
match a process by name, user or PID. Click the ✕ button on a row to run the
configured kill command against that PID (default `kill -TERM`). Zombie
processes are tinted with the error color so they stand out.
The table refreshes on the interval set in the `refresh_interval` setting. The
panel re-renders automatically as new samples arrive, so the view stays live
while it is open.
@@ -48,13 +46,15 @@ while it is open.
## Notes
- **Spawns processes.** A background service runs `ps` on every refresh
interval and `runAsync` runs the configured `kill_command` when a row's ✕ is
- **Spawns processes.** A background service runs `ps` every 2nd refresh
interval (the process table changes slowly) and reads `/proc` stats every
interval; `runAsync` runs the configured `kill_command` when a row's ✕ is
clicked. There is no confirmation dialog, so check the PID before clicking.
- The bar widget only renders data the service publishes; it never runs
commands. The panel runs the configured `kill_command` when a row's ✕ is
clicked.
- Requires `plugin_api = 12`. CPU%, RAM%, swap and the 1/5/15-minute load
- Requires `plugin_api = 13`. CPU%, RAM%, swap and the 1/5/15-minute load
averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`,
`/proc/loadavg`) by the service, so they work with no separate system-monitor
dependency.
+150 -14
View File
@@ -5,6 +5,8 @@
local sortKey = "cpu"
local sortDir = "desc"
local filter = ""
local filterRev = 0 -- bumped to reseed the filter input so it can grab focus
local focusFilterOnRender = false -- one-shot: focuses the filter on the next render
-- Per-column display unit: "pct" shows percent, "raw" shows the absolute
-- figure. Clicking the %CPU/%MEM header toggles it. MEM raw = RSS in MB; CPU
@@ -19,6 +21,11 @@ local memUnit = "pct"
-- processes are always visible. (200 rows * ~7 ui nodes blew the budget.)
local MAX_ROWS = 80
-- Keyboard navigation state (btop-style): selectedIdx is the cursor position
-- in the currently visible (filtered/sorted/trimmed) list; 0 means nothing is
-- selected. Ctrl+D kills the selected process directly.
local selectedIdx = 0
-- Cheap fingerprint of everything the table shows, so the 1s tick only rebuilds
-- the heavy UI tree when the data actually changed. Rebuilding 200 rows every
-- second exceeded the panel update CPU budget; sampling the first SIG_SAMPLE
@@ -29,10 +36,14 @@ local lastSig = nil
-- Throttle data-driven renders. The service can sample faster than the panel can
-- rebuild its table, and every state.set fires a watch: at 250ms that was ~16
-- full rebuilds/sec of 80 rows, which blew the panel update CPU budget and got
-- the panel disabled. User interactions (toggle/filter/sort) still render()
-- immediately; only the data watches and the tick go through maybeRender().
-- the panel disabled. 2000ms bounds rebuilds to 1/2s, well under the budget.
-- The service samples stats every 1s and the process table every 2s, so a 2s
-- rebuild floor keeps the panel in lockstep with the table without re-rendering
-- the whole 80-row tree on every stats tick.
-- User interactions (toggle/filter/sort) still render() immediately; only the
-- data watches and the tick go through maybeRender().
local lastRenderMs = 0
local MIN_RENDER_MS = 500
local MIN_RENDER_MS = 2000
-- Kill column is a fixed width at the end of every row; all other columns are
-- flexGrow shares so the header and data rows always align and never overflow.
@@ -193,7 +204,7 @@ local function toggleMemUnit()
render()
end
local function dataRow(p)
local function dataRow(p, idx)
local stat = p.stat or "?"
local statColor = stat:find("Z") and "error" or "on_surface"
local memCell = memUnit == "raw" and fmtMem(p.rssMb) or string.format("%.1f", p.mem)
@@ -206,6 +217,20 @@ local function dataRow(p)
{ stat, statColor },
}
-- Cursor highlight mirrors the signal picker: a translucent fill plus the
-- text flipped to primary. Zombie rows keep their error tint so the warning
-- survives selection. The row key bakes in the selection state so a moved
-- cursor rebuilds exactly the rows whose selection flipped -- a stable pid
-- key would leave the fill stuck on rows the cursor passed over.
local selected = idx == selectedIdx
local function cellColor(c)
local color = c[2] or "on_surface"
if selected and color ~= "error" then
return "primary"
end
return color
end
local children = {}
for i, c in ipairs(cells) do
table.insert(children, ui.label({
@@ -214,7 +239,7 @@ local function dataRow(p)
textAlign = COLS[i][2],
fontSize = 11,
maxLines = 1,
color = c[2] or "on_surface",
color = cellColor(c),
}))
end
@@ -225,6 +250,7 @@ local function dataRow(p)
textAlign = "start",
fontSize = 11,
maxLines = 1,
color = selected and "primary" or "on_surface",
}))
-- Kill button (fixed trailing width)
@@ -240,7 +266,19 @@ local function dataRow(p)
end,
}))
return ui.row({ key = tostring(p.pid), gap = 6, align = "center" }, children)
return ui.row({
key = tostring(p.pid) .. (selected and "|sel" or ""),
gap = 6,
align = "center",
fill = selected and "primary/0.25" or nil,
radius = 6,
paddingH = 8,
paddingV = 2,
onClick = function()
selectedIdx = idx
render()
end,
}, children)
end
-- Header: clickable cells use ui.row onClick (keeps layout, so the grid stays
@@ -285,10 +323,13 @@ end
-- ── main render ─────────────────────────────────────────────────────────────
function render()
-- The currently visible rows: filtered by `filter`, sorted by the active
-- column, capped at MAX_ROWS. Shared by render() and the keyboard handlers so
-- onKey resolves the cursor position against the exact same list the panel
-- draws (never trust an index computed from a stale render).
local function visibleList()
local procs = st("procs") or {}
local f = filter:lower()
local list = {}
for _, p in ipairs(procs) do
if f ~= "" then
@@ -325,12 +366,29 @@ function render()
end)
-- Bound the rendered rows so a 500-process box stays responsive.
local shown = list
if #list > MAX_ROWS then
shown = {}
local shown = {}
for i = 1, MAX_ROWS do
shown[i] = list[i]
end
return shown, #list
end
return list, #list
end
function render()
local wantsFocus = focusFilterOnRender
focusFilterOnRender = false
local shown, totalCount = visibleList()
-- Keep the cursor inside the visible list. A shrunk list (filter/sort/data
-- change) clamps up; an empty list clears the cursor.
if #shown == 0 then
selectedIdx = 0
elseif selectedIdx > #shown then
selectedIdx = #shown
elseif selectedIdx == 0 then
selectedIdx = 1
end
local body
@@ -340,8 +398,8 @@ function render()
})
else
local itemRows = {}
for _, p in ipairs(shown) do
table.insert(itemRows, dataRow(p))
for i, p in ipairs(shown) do
table.insert(itemRows, dataRow(p, i))
end
body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
end
@@ -358,11 +416,12 @@ function render()
renderStats(),
ui.row({ gap = 6, align = "center" }, {
ui.input({
key = "filter",
key = "filter-" .. filterRev,
value = filter,
placeholder = tr("panel.search_placeholder"),
controlSize = "sm",
flexGrow = 1,
focus = wantsFocus,
onChange = function(v)
filter = v
render()
@@ -397,7 +456,7 @@ function render()
ui.row({ gap = 6, align = "center" }, buildHeader()),
body,
ui.row({ gap = 10, align = "center" }, {
ui.label({ text = tr("panel.count", { n = #list }), fontSize = 11, color = "on_surface_variant" }),
ui.label({ text = tr("panel.count", { n = totalCount }), fontSize = 11, color = "on_surface_variant" }),
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.label({ text = tr("panel.refresh"), fontSize = 11, color = "on_surface_variant" }),
ui.slider({
@@ -410,6 +469,9 @@ function render()
}),
ui.label({ text = err, fontSize = 11, color = "error" }),
}),
ui.row({ gap = 8, align = "center" }, {
ui.label({ text = tr("panel.keys_hint"), fontSize = 10, color = "on_surface_variant" }),
}),
}))
lastSig = signature()
end
@@ -452,9 +514,83 @@ function update()
maybeRender()
end
-- Keyboard control (btop-style). Chords must be listed in the panel's
-- `capture_keys` in plugin.toml, otherwise onKey never fires.
--
-- List mode: up/down move the cursor, Ctrl+D opens the signal picker for the
-- selected process, Ctrl+F focuses the filter box. Menu mode: up/down move the
-- signal highlight, Return sends it, Ctrl+D or Esc closes the menu. Ctrl-modifier
-- chords are used (not bare letters) so they never collide with the filter
-- input's text keys.
--
-- Keyboard chords. The runtime delivers chords as clean down/up pairs and
-- does not tag auto-repeat events, so holding a chord fires repeated presses.
-- The keyHeld guard (release-based, no time gate) blocks those repeats until
-- the release arrives; genuine taps clear on release, so they stay instant.
-- The short TTL only covers a rare missed release, never a tap.
local keyHeld = {}
local KEY_HELD_TTL_MS = 150
local function keyFresh(chord)
local now = noctalia.nowMs()
local heldAt = keyHeld[chord]
if heldAt and now - heldAt < KEY_HELD_TTL_MS then
return false
end
keyHeld[chord] = now
return true
end
function onKey(chord, pressed)
if not pressed then
keyHeld[chord] = nil
return
end
-- Arrow keys repeat freely (holding Down scrolls the table / menu); the
-- guard only covers action chords where an auto-repeat would flip state.
if chord ~= "up" and chord ~= "down" and not keyFresh(chord) then
return
end
if chord == "up" or chord == "down" then
local list = visibleList()
if #list > 0 then
selectedIdx = math.max(1, math.min(#list, selectedIdx + (chord == "down" and 1 or -1)))
render()
end
return
end
if chord == "ctrl+d" then
-- Kill the selected process directly (same configured kill_command the
-- row's ✕ button runs). No confirmation, matching the ✕ button.
local list = visibleList()
local p = list[selectedIdx]
if p then
killPid(p.pid)
end
return
end
if chord == "ctrl+f" then
filterRev += 1
focusFilterOnRender = true
render()
return
end
if chord == "escape" then
-- Swallow Escape so it never closes the whole panel.
return
end
end
function onOpen(_context)
sortKey = cfg("sort_by") or "cpu"
sortDir = "desc"
filter = ""
-- No auto-focus on the filter: the panel opens in list mode so the arrow
-- keys and Ctrl+D work immediately (btop-style). Press Ctrl+F to start
-- typing a filter; the box is focused then.
selectedIdx = 0
render()
end
+3 -2
View File
@@ -1,7 +1,7 @@
id = "weinguyen/procmon"
name = "Process Monitor"
version = "0.3.0"
plugin_api = 12
version = "0.4.0"
plugin_api = 13
author = "weinguyen"
license = "MIT"
icon = "cpu"
@@ -71,6 +71,7 @@ height = 660
placement = "floating"
position = "center"
open_near_click = true
capture_keys = ["ctrl+f", "ctrl+d", "up", "down", "escape"]
[[service]]
id = "service"
+36 -26
View File
@@ -36,13 +36,14 @@ end
-- `args` is placed last so the fixed fields are positional and everything
-- after them is the full command line (which may contain spaces). The shell
-- pre-sorts by CPU and truncates to the top 120 rows so the Luau parse stays
-- pre-sorts by CPU and truncates to the top 80 rows so the Luau parse stays
-- tiny: parsing every process blew the async callback's CPU budget even with a
-- single-pass gmatch. btm shows a screenful anyway, so top-120 is plenty.
-- single-pass gmatch. The panel caps at 80 rows (MAX_ROWS), so parsing more is
-- wasted work -- top-80 is exactly what gets displayed.
-- The trailing ##STAT/##MEM sections carry real total CPU/RAM from /proc so the
-- bars read the machine's actual usage -- ps %cpu is a per-process lifetime
-- average, so summing the top rows and capping at 100 always pegged the bar.
local PS_CMD = "ps -eo pid=,user=,%cpu=,%mem=,rss=,stat=,time=,args= --sort=-%cpu | head -n 120"
local PS_CMD = "ps -eo pid=,user=,%cpu=,%mem=,rss=,stat=,time=,args= --sort=-%cpu | head -n 80"
-- Real total CPU/RAM come from /proc in a SEPARATE, tiny command so the async
-- callback that parses it stays far under the CPU budget. Folding the markers
@@ -170,35 +171,22 @@ local function sampleRam(raw)
return math.floor(usedKb / totalKb * 100 + 0.5), math.floor(usedKb / 1024), math.floor(totalKb / 1024)
end
local sampling = false
-- inFlight counts outstanding async callbacks. sample() only starts when it is
-- 0, so a slow `ps` can never overlap the next tick's subprocesses -- overlapping
-- spawns were a big part of the CPU-budget blowout.
local inFlight = 0
local tick = 0
local function sample()
if sampling then
if inFlight > 0 then
return -- one in-flight sample at a time
end
sampling = true
noctalia.runAsync(PS_CMD, function(res)
local ok, perr = pcall(function()
sampling = false
if res == nil or res.timedOut or res.exitCode ~= 0 then
local detail = res and (res.stderr or res.stdout or "") or "no result"
setState("err", detail)
return
end
local procs = parse(res.stdout)
setState("procs", procs)
setState("refreshedAtMs", os.time() * 1000)
end)
if not ok then
sampling = false
log("ps callback error: " .. tostring(perr))
end
end, PS_TIMEOUT_MS)
tick += 1
-- Cheap /proc stats every tick: the bars stay real-time.
inFlight += 1
noctalia.runAsync(STATS_CMD, function(res)
local ok, perr = pcall(function()
sampling = false
if res == nil or res.timedOut or res.exitCode ~= 0 then
return
end
@@ -215,10 +203,32 @@ local function sample()
setState("err", "")
end)
if not ok then
sampling = false
log("stats callback error: " .. tostring(perr))
end
inFlight -= 1
end, PS_TIMEOUT_MS)
-- The `ps` subprocess is the biggest CPU cost; the process table changes
-- slowly, so sample it every 2nd tick while stats stay fresh every tick.
if tick % 2 == 1 then
inFlight += 1
noctalia.runAsync(PS_CMD, function(res)
local ok, perr = pcall(function()
if res == nil or res.timedOut or res.exitCode ~= 0 then
local detail = res and (res.stderr or res.stdout or "") or "no result"
setState("err", detail)
return
end
local procs = parse(res.stdout)
setState("procs", procs)
setState("refreshedAtMs", os.time() * 1000)
end)
if not ok then
log("ps callback error: " .. tostring(perr))
end
inFlight -= 1
end, PS_TIMEOUT_MS)
end
end
-- Seed placeholders so subscribers have something before the first sample.
+44 -43
View File
@@ -1,59 +1,60 @@
{
"panel": {
"col": {
"cmd": "COMMAND",
"cpu": "CPU%",
"cpu_raw": "CPU",
"mem": "MEM%",
"mem_raw": "MEM",
"pid": "PID",
"rss": "RSS",
"stat": "S"
},
"count": "{n} processes",
"kill_tip": "Kill {pid}",
"load": "Load",
"no_processes": "No matching processes",
"refresh": "Refresh",
"refreshed_at": "Updated {time}",
"search_placeholder": "Filter by name, user or PID",
"sort_asc": "asc",
"sort_desc": "desc",
"title": "Processes"
},
"settings": {
"glyph": {
"description": "Glyph shown in the bar widget.",
"label": "Widget icon"
},
"icon_only": {
"description": "Show only a CPU icon in the bar widget; details move to the tooltip.",
"label": "Icon only"
},
"kill_command": {
"description": "Command run against a selected PID (the PID is appended). Empty uses kill -TERM.",
"label": "Kill command"
},
"refresh_interval": {
"description": "How often the process list is resampled.",
"label": "Refresh interval (ms)"
},
"show_count": {
"description": "Append the number of processes to the bar widget text.",
"label": "Show process count"
"label": "Refresh interval (ms)",
"description": "How often the process list is resampled."
},
"sort_by": {
"description": "Column the process table is sorted by when the panel opens.",
"label": "Default sort column",
"description": "Column the process table is sorted by when the panel opens.",
"options": {
"cmd": "Command",
"cpu": "CPU",
"mem": "Memory",
"pid": "PID"
"pid": "PID",
"cmd": "Command"
}
},
"kill_command": {
"label": "Kill command",
"description": "Command run against a selected PID (the PID is appended). Empty uses kill -TERM."
},
"show_count": {
"label": "Show process count",
"description": "Append the number of processes to the bar widget text."
},
"glyph": {
"label": "Widget icon",
"description": "Glyph shown in the bar widget."
},
"icon_only": {
"label": "Icon only",
"description": "Show only a CPU icon in the bar widget; details move to the tooltip."
}
},
"widget": {
"tooltip": "CPU {cpu}% RAM {ram}% · {n} processes"
},
"panel": {
"title": "Processes",
"refreshed_at": "Updated {time}",
"refresh": "Refresh",
"load": "Load",
"search_placeholder": "Filter by name, user or PID",
"sort_asc": "asc",
"sort_desc": "desc",
"col": {
"pid": "PID",
"cpu": "CPU%",
"cpu_raw": "CPU",
"mem": "MEM%",
"mem_raw": "MEM",
"rss": "RSS",
"stat": "S",
"cmd": "COMMAND"
},
"count": "{n} processes",
"no_processes": "No matching processes",
"kill_tip": "Kill {pid}",
"keys_hint": "Ctrl+F filter · Ctrl+D kill selected · arrows move"
}
}
+36 -21
View File
@@ -1,7 +1,12 @@
--!nonstrict
-- Process Monitor — bar widget. Full mode shows "CPU% RAM% · count" text; icon-only
-- mode shows just a CPU icon colored by load, with the details in the tooltip so the
-- bar stays tiny. Clicking opens the panel.
-- Process Monitor — bar widget. Horizontal bars show "CPU% RAM%" text (truncated
-- to fit); vertical bars are too narrow for text, so they show only a CPU icon
-- colored by load. Clicking opens the panel.
-- Read the bar orientation once at load (pomodoro pattern) so the widget renders
-- consistently instead of flickering between icon and text when isVertical() is
-- polled on every update.
local isVertical = barWidget.isVertical()
local function cfg(key)
return noctalia.getConfig(key)
@@ -15,6 +20,14 @@ local function tr(key, subst)
return noctalia.tr(key, subst)
end
-- Cap the text so it fits the bar instead of spilling out the right edge.
local function trunc(s, n)
if #s <= n then
return s
end
return s:sub(1, n - 1) .. "…"
end
function update()
local stats = st("stats")
local cpu = stats and stats.cpu and stats.cpu.usagePercent
@@ -23,28 +36,30 @@ function update()
local cpuInt = math.floor(cpu or 0)
local tooltip = tr("widget.tooltip", { cpu = cpuInt, ram = math.floor(ram or 0), n = count })
barWidget.setGlyph(cfg("glyph") or "cpu")
barWidget.setTooltip(tooltip)
-- Color the icon by CPU load so a glance reads the machine state.
-- Only real theme tokens (error/secondary/on_surface) — the runtime rejects
-- unknown names like "warning" and logs a warning per glyph per frame.
local color = "on_surface"
if cpuInt >= 80 then
color = "error"
elseif cpuInt >= 50 then
color = "secondary"
end
if cfg("icon_only") then
barWidget.setText("")
-- color the icon by CPU load so a glance reads the machine state
if cpuInt >= 80 then
barWidget.setGlyphColor("error")
elseif cpuInt >= 50 then
barWidget.setGlyphColor("warning")
else
barWidget.setGlyphColor("on_surface")
local children = { ui.glyph({ name = cfg("glyph") or "cpu", size = 14, color = color }) }
-- Vertical bars are too narrow for text; show only the icon so it fits.
if not isVertical and not cfg("icon_only") then
local text = string.format("CPU %d%% RAM %d%%", cpuInt, math.floor(ram or 0))
if cfg("show_count") then
text = text .. " · " .. count
end
return
table.insert(children, ui.label({ text = text, fontSize = 11 }))
end
barWidget.setGlyphColor("on_surface")
local text = string.format("CPU %d%% RAM %d%%", cpuInt, math.floor(ram or 0))
if cfg("show_count") then
text = text .. " · " .. count
end
barWidget.setText(text)
local container = isVertical and ui.column or ui.row
barWidget.render(container({ gap = 6, align = "center" }, children))
barWidget.setTooltip(tooltip)
end
noctalia.state.watch("stats", update)