Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions procmon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ 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
match a process by name, user or PID. When a filter is active, the footer shows
the summed RSS ("Σ RAM") of every matching process — handy for apps like Brave
that fan out into many processes. 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
Expand All @@ -54,7 +56,8 @@ while it is open.

- 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.
clicked, and a debounced `ps | awk` query to sum RSS when a filter is active
(the "Σ RAM" figure).
- Requires `plugin_api = 13`. The panel renders a windowed slice of the
process table and follows the keyboard cursor with edge-follow scrolling
(window slides only when the cursor pushes past an edge, so scrolling up
Expand Down
45 changes: 43 additions & 2 deletions procmon/panel.luau
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ local function signature()
local s = tostring(n) .. "|" .. filter .. "|" .. sortKey .. sortDir
.. "|" .. tostring(cpuPct) .. "|" .. tostring(ramPct)
.. "|" .. cpuUnit .. memUnit
.. "|" .. tostring(st("rssTotalMb") or 0) .. "|" .. tostring(st("rssFilterMb") or 0)
local upto = n < SIG_SAMPLE and n or SIG_SAMPLE
for i = 1, upto do
local p = procs[i]
Expand Down Expand Up @@ -139,6 +140,10 @@ local function killPid(pid)
noctalia.runAsync(cmd .. " " .. pid)
end

local function killPidForce(pid)
noctalia.runAsync("kill -KILL " .. pid)
end

-- ── stats bars ──────────────────────────────────────────────────────────────

local function statBar(label, glyph, pct, sub)
Expand Down Expand Up @@ -477,6 +482,25 @@ function render()
local refreshedStr = refreshedAt > 0 and os.date("%H:%M:%S", refreshedAt) or "—"
local err = st("err")

-- Footer count + summed RAM (total when no filter, filtered when set).
-- The service computes both every tick and publishes them to state. Fall back
-- to the total while the filtered value is still being computed, so the label
-- never flickers away on a keystroke.
local ramSum = st("rssTotalMb")
if filter ~= "" and st("rssFilterMb") ~= nil then
ramSum = st("rssFilterMb")
end
local countRow = {
ui.label({ text = tr("panel.count", { n = totalCount }), fontSize = 11, color = "on_surface_variant" }),
}
if ramSum ~= nil then
table.insert(countRow, ui.label({
text = tr("panel.ram_sum", { ram = fmtMem(ramSum) }),
fontSize = 11,
color = "primary",
}))
end

panel.render(ui.column({ padding = 12, gap = 8, flexGrow = 1, align = "stretch" }, {
ui.row({ align = "center", justify = "space_between" }, {
ui.label({ text = tr("panel.title"), fontSize = 15, fontWeight = "bold" }),
Expand All @@ -493,6 +517,7 @@ function render()
focus = wantsFocus,
onChange = function(v)
filter = v
noctalia.state.set("filter", v)
render()
end,
}),
Expand Down Expand Up @@ -525,7 +550,7 @@ function render()
ui.row({ gap = 6, align = "center" }, buildHeader()),
body,
ui.row({ gap = 10, align = "center" }, {
ui.label({ text = tr("panel.count", { n = totalCount }), fontSize = 11, color = "on_surface_variant" }),
ui.row({ gap = 10, align = "center" }, countRow),
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.label({ text = tr("panel.refresh"), fontSize = 11, color = "on_surface_variant" }),
ui.slider({
Expand All @@ -538,8 +563,11 @@ function render()
}),
ui.label({ text = err, fontSize = 11, color = "error" }),
}),
ui.row({ gap = 8, align = "center" }, {
ui.row({ gap = 4, align = "center" }, {
ui.label({ text = tr("panel.keys_hint"), fontSize = 10, color = "on_surface_variant" }),
ui.glyph({ name = "arrow-up", size = 12, color = "on_surface_variant" }),
ui.glyph({ name = "arrow-down", size = 12, color = "on_surface_variant" }),
ui.label({ text = tr("panel.move"), fontSize = 10, color = "on_surface_variant" }),
}),
}))
lastSig = signature()
Expand All @@ -563,6 +591,8 @@ noctalia.state.watch("procs", maybeRender)
noctalia.state.watch("stats", maybeRender)
noctalia.state.watch("refreshedAtMs", maybeRender)
noctalia.state.watch("err", maybeRender)
noctalia.state.watch("rssTotalMb", maybeRender)
noctalia.state.watch("rssFilterMb", maybeRender)
noctalia.state.watch("refreshMs", function()
-- Track the fetch speed on the panel's own tick so the table stays live when
-- the user drops the interval below the 1s second-tick floor.
Expand Down Expand Up @@ -658,6 +688,16 @@ function onKey(chord, pressed)
return
end

if chord == "ctrl+k" then
-- Force-kill (SIGKILL) the selected process, bypassing kill_command.
local list = visibleList()
local p = list[selectedIdx]
if p then
killPidForce(p.pid)
end
return
end

if chord == "ctrl+f" then
filterRev += 1
focusFilterOnRender = true
Expand All @@ -675,6 +715,7 @@ function onOpen(_context)
sortKey = cfg("sort_by") or "cpu"
sortDir = "desc"
filter = ""
noctalia.state.set("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.
Expand Down
4 changes: 2 additions & 2 deletions procmon/plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "weinguyen/procmon"
name = "Process Monitor"
version = "0.5.0"
version = "0.6.0"
plugin_api = 13
author = "weinguyen"
license = "MIT"
Expand Down Expand Up @@ -71,7 +71,7 @@ height = 660
placement = "floating"
position = "center"
open_near_click = true
capture_keys = ["ctrl+f", "ctrl+d", "up", "down", "escape"]
capture_keys = ["ctrl+f", "ctrl+d", "ctrl+k", "up", "down", "escape"]

[[service]]
id = "service"
Expand Down
56 changes: 56 additions & 0 deletions procmon/service.luau
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ local function log(msg)
noctalia.log("[procmon/service] " .. msg)
end

local function shq(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
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 80 rows so the Luau parse stays
Expand Down Expand Up @@ -63,6 +67,20 @@ local ROW = "(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+([^\n]*)"
-- Upper bound on how long `ps` may take before we give up on a sample.
local PS_TIMEOUT_MS = 5000

-- Sum of RSS across ALL processes (single number). No filter, so no self-match
-- concern — the query's own transient processes (sh/ps/awk) add a negligible
-- ~17MB to the total.
local RSS_TOTAL_CMD = "ps -eo rss= | awk '{s += $1} END {print s+0}'"

-- Sum of RSS for processes matching a filter. The filter text appears in the
-- query's own command lines (sh -c wrapper, ps, awk), so they'd self-match;
-- exclude the shell by PID ($$) and ps/awk by comm. index() = plain substring
-- (mirrors the panel's find(f,1,true)); tolower() = case-insensitive.
local function rssFilterCmd(f)
local awk = "index(tolower($0), tolower(f)) && $2 != self && $3 != \"awk\" && $3 != \"ps\" {s += $1} END {print s+0}"
return "ps -eo rss=,pid=,comm=,args= | awk -v f=" .. shq(f) .. " -v self=$$ '" .. awk .. "'"
end

local function parse(raw)
local list, n = {}, 0
for pidS, user, cpuS, memS, rssS, stat, timeS, args in raw:gmatch(ROW) do
Expand Down Expand Up @@ -208,6 +226,41 @@ local function sample()
inFlight -= 1
end, PS_TIMEOUT_MS)

-- Total RSS of all processes, every tick (cheap single-number sum).
inFlight += 1
noctalia.runAsync(RSS_TOTAL_CMD, function(res)
local ok, perr = pcall(function()
if res and res.exitCode == 0 and not res.timedOut then
local kb = tonumber(res.stdout:match("%d+"))
setState("rssTotalMb", kb and (kb / 1024) or 0)
end
end)
if not ok then
log("rss total callback error: " .. tostring(perr))
end
inFlight -= 1
end, PS_TIMEOUT_MS)

-- Filtered RSS, when a filter is active (published by the panel).
local f = st("filter") or ""
if f ~= "" then
inFlight += 1
noctalia.runAsync(rssFilterCmd(f), function(res)
local ok, perr = pcall(function()
if res and res.exitCode == 0 and not res.timedOut then
local kb = tonumber(res.stdout:match("%d+"))
setState("rssFilterMb", kb and (kb / 1024) or 0)
end
end)
if not ok then
log("rss filter callback error: " .. tostring(perr))
end
inFlight -= 1
end, PS_TIMEOUT_MS)
else
setState("rssFilterMb", nil)
end

-- 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
Expand Down Expand Up @@ -236,6 +289,9 @@ setState("procs", {})
setState("stats", { cpu = { usagePercent = 0 }, ram = { usagePercent = 0, usedMb = 0, totalMb = 0 } })
setState("refreshedAtMs", 0)
setState("err", "")
setState("rssTotalMb", 0)
setState("rssFilterMb", nil)
setState("filter", "")

local function refreshInterval()
return tonumber(st("refreshMs") or cfg("refresh_interval")) or 1000
Expand Down
4 changes: 3 additions & 1 deletion procmon/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"stat": "S"
},
"count": "{n} processes",
"keys_hint": "Ctrl+F filter · Ctrl+D kill selected · arrows move",
"ram_sum": "Σ RSS {ram}",
"keys_hint": "Ctrl+F filter · Ctrl+D kill · Ctrl+K force kill ·",
"move": "move",
"kill_tip": "Kill {pid}",
"load": "Load",
"no_processes": "No matching processes",
Expand Down
Loading