diff --git a/procmon/README.md b/procmon/README.md index 9d3067af..8659c667 100644 --- a/procmon/README.md +++ b/procmon/README.md @@ -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 @@ -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 diff --git a/procmon/panel.luau b/procmon/panel.luau index 46ea8f46..961865f1 100644 --- a/procmon/panel.luau +++ b/procmon/panel.luau @@ -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] @@ -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) @@ -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" }), @@ -493,6 +517,7 @@ function render() focus = wantsFocus, onChange = function(v) filter = v + noctalia.state.set("filter", v) render() end, }), @@ -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({ @@ -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() @@ -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. @@ -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 @@ -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. diff --git a/procmon/plugin.toml b/procmon/plugin.toml index ede308ce..d54e983a 100644 --- a/procmon/plugin.toml +++ b/procmon/plugin.toml @@ -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" @@ -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" diff --git a/procmon/service.luau b/procmon/service.luau index 6e6c3b7d..3c389a43 100644 --- a/procmon/service.luau +++ b/procmon/service.luau @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/procmon/translations/en.json b/procmon/translations/en.json index 3250f7d3..62153321 100644 --- a/procmon/translations/en.json +++ b/procmon/translations/en.json @@ -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",