From 50573d4e79db912f6630c64e196dd4d47347674f Mon Sep 17 00:00:00 2001 From: Nguyen Tien Duy Date: Wed, 12 Aug 2026 01:13:25 +0700 Subject: [PATCH 1/5] update(procmon): fix windowed table follow-scroll & row sizing` --- procmon/README.md | 4 ++- procmon/panel.luau | 87 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/procmon/README.md b/procmon/README.md index 6be83342..99b1becc 100644 --- a/procmon/README.md +++ b/procmon/README.md @@ -25,6 +25,7 @@ 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 @@ -54,7 +55,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. -- Requires `plugin_api = 13`. CPU%, RAM%, swap and the 1/5/15-minute load +- Requires `plugin_api = 21` (uses UI scroll-follow for keyboard navigation and + the `/proc` sampler). 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. diff --git a/procmon/panel.luau b/procmon/panel.luau index a4a30843..c29991fe 100644 --- a/procmon/panel.luau +++ b/procmon/panel.luau @@ -26,6 +26,23 @@ local MAX_ROWS = 80 -- selected. Ctrl+D kills the selected process directly. local selectedIdx = 0 +-- Windowed table rendering, edge-follow (htop/btop style). The panel renders +-- only a VIEW_CAP-row slice [winStart..winEnd] that fits the table viewport. +-- Rows ~30px (kill button height=26 + paddingV 2), so 10 fill the 660px panel +-- without clipping the last one (tuned so no dead gap sits below the list). +-- The cursor moves freely INSIDE the window; the window slides only when the +-- cursor pushes past an edge — down at the bottom edge, up at the top edge. So +-- scrolling back up from the bottom does not collapse the page: the rows above +-- (6,7,8,9) stay in view while the highlight climbs, and only reaching the +-- window's top edge starts following up again. There is no host "scroll to row +-- N" (API 21 only jumps to absolute bottom), so the slice is what the panel +-- draws and the window IS the visible page. +-- ponytail: VIEW_CAP is tuned to the panel's table height; could derive it from +-- a scroll fill-measure if the API ever exposes available height. +local VIEW_CAP = 10 +local winStart = 1 +local winEnd = VIEW_CAP + -- 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 @@ -253,12 +270,14 @@ local function dataRow(p, idx) color = selected and "primary" or "on_surface", })) - -- Kill button (fixed trailing width) + -- Kill button (fixed trailing width). Explicit height keeps the row short + -- enough that VIEW_CAP rows fit without clipping the last one. table.insert(children, ui.button({ glyph = "square-x", glyphSize = 14, variant = "ghost", controlSize = "sm", + height = 26, width = KILL_W, tooltip = tr("panel.kill_tip", { pid = p.pid }), onClick = function() @@ -376,6 +395,43 @@ local function visibleList() return list, #list end +-- Keep the window [winStart..winEnd] covering the cursor and inside the list. +-- Called from render() after the cursor is clamped, so it also heals the window +-- when the list shrank (filter/sort/data) and left the cursor or window out of +-- bounds. The lazy up-follow during navigation lives in onKey, not here (this +-- only heals shrink/regrow). +local function clampWindow(n) + if n == 0 then + winStart, winEnd = 1, 0 + return + end + -- Cursor below the window (list regrew or follow-down persisted): extend. + if selectedIdx > winEnd then + winEnd = selectedIdx + winStart = math.max(1, winEnd - VIEW_CAP + 1) + -- Cursor above the window: the list shrank (a filter) or re-sorted and left the + -- cursor outside. Flatten back to the top page — cleared search shows row 1. + elseif selectedIdx < winStart then + winStart = 1 + winEnd = math.min(VIEW_CAP, n) + end + -- Never let the window extend past the list nor below row 1. + if winEnd > n then + winEnd = n + winStart = math.max(1, winEnd - VIEW_CAP + 1) + end + if winStart < 1 then + winStart = 1 + end + -- Re-expand a below-capacity window to a full page. Only fires when the list + -- shrank (filter) then regrew: otherwise the window would stay a few rows wide + -- and grow one row per Down press. Filling back to VIEW_CAP makes a cleared + -- search show the whole page again. No-op during normal navigation. + if n >= VIEW_CAP and winEnd - winStart + 1 < VIEW_CAP then + winEnd = math.min(winStart + VIEW_CAP - 1, n) + end +end + function render() local wantsFocus = focusFilterOnRender focusFilterOnRender = false @@ -397,9 +453,12 @@ function render() ui.label({ text = tr("panel.no_processes"), color = "on_surface_variant" }), }) else + clampWindow(#shown) local itemRows = {} - for i, p in ipairs(shown) do - table.insert(itemRows, dataRow(p, i)) + for i = winStart, winEnd do + if shown[i] then + table.insert(itemRows, dataRow(shown[i], i)) + end end body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows) end @@ -554,7 +613,25 @@ function onKey(chord, pressed) 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))) + if chord == "down" then + selectedIdx = math.min(#list, selectedIdx + 1) + -- Cursor pushed past the bottom edge: extend the window one row and + -- slide its top so the new row is revealed below the cursor. + if selectedIdx > winEnd then + winEnd = selectedIdx + winStart = math.max(1, winEnd - VIEW_CAP + 1) + end + else + selectedIdx = math.max(1, selectedIdx - 1) + -- Cursor rose past the top edge: only now follow up — slide the window's + -- top to the cursor and refill down. Rows inside the window (6,7,8,9 + -- while stepping down from 10) never shift the window; reaching below the + -- top edge (5) is what starts scrolling up again. + if selectedIdx < winStart then + winStart = selectedIdx + winEnd = math.min(winStart + VIEW_CAP - 1, #list) + end + end render() end return @@ -592,5 +669,7 @@ function onOpen(_context) -- keys and Ctrl+D work immediately (btop-style). Press Ctrl+F to start -- typing a filter; the box is focused then. selectedIdx = 0 + winStart = 1 + winEnd = VIEW_CAP render() end From 473141f155500d987857843be94a7a8b1ab0fb0a Mon Sep 17 00:00:00 2001 From: Nguyen Tien Duy Date: Wed, 12 Aug 2026 01:16:56 +0700 Subject: [PATCH 2/5] Update plugin.toml --- procmon/plugin.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/procmon/plugin.toml b/procmon/plugin.toml index 0b177b49..ede308ce 100644 --- a/procmon/plugin.toml +++ b/procmon/plugin.toml @@ -1,6 +1,6 @@ id = "weinguyen/procmon" name = "Process Monitor" -version = "0.4.0" +version = "0.5.0" plugin_api = 13 author = "weinguyen" license = "MIT" From c23973c9eebc162c0aa997ada16bef80f4ffd2da Mon Sep 17 00:00:00 2001 From: Nguyen Tien Duy Date: Wed, 12 Aug 2026 01:21:10 +0700 Subject: [PATCH 3/5] Update README.md --- procmon/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/procmon/README.md b/procmon/README.md index 99b1becc..9d3067af 100644 --- a/procmon/README.md +++ b/procmon/README.md @@ -55,8 +55,10 @@ 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. -- Requires `plugin_api = 21` (uses UI scroll-follow for keyboard navigation and - the `/proc` sampler). 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. +- 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 + doesn't collapse the page until the top edge is reached). 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. From 3e8a013fe6cdd12895a2a0b10d5c77c6d16cae7a Mon Sep 17 00:00:00 2001 From: Nguyen Tien Duy Date: Wed, 12 Aug 2026 19:39:20 +0700 Subject: [PATCH 4/5] fix(procmon): always render list body as scroll to stop bottom clipping 0-result search switched the body from ui.scroll to a bare ui.row; when results returned the row->scroll switch left the flexGrow viewport stuck short, clipping bottom rows. Use ui.scroll with a shared key in both branches so the tree keeps one stable scroll node. --- procmon/panel.luau | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/procmon/panel.luau b/procmon/panel.luau index c29991fe..46ea8f46 100644 --- a/procmon/panel.luau +++ b/procmon/panel.luau @@ -449,8 +449,18 @@ function render() local body if #shown == 0 then - body = ui.row({ align = "center", justify = "center", flexGrow = 1 }, { - ui.label({ text = tr("panel.no_processes"), color = "on_surface_variant" }), + -- Keep the body a ui.scroll in BOTH states so the flexGrow container keeps + -- a stable height. Rendering a bare ui.row here (and switching back to a + -- scroll once results return) left the scroll container stuck short, which + -- clipped the bottom of the list after a 0-result search. + winStart, winEnd = 1, 0 + -- Same stable key as the results scroll so the UI tree reconciles this as + -- one persistent scroll node across the 0-result <-> results transition, + -- keeping the flexGrow viewport height stable (avoids bottom clipping). + body = ui.scroll({ key = "proc-list", flexGrow = 1 }, { + ui.row({ align = "center", justify = "center", flexGrow = 1 }, { + ui.label({ text = tr("panel.no_processes"), color = "on_surface_variant" }), + }), }) else clampWindow(#shown) @@ -460,7 +470,7 @@ function render() table.insert(itemRows, dataRow(shown[i], i)) end end - body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows) + body = ui.scroll({ key = "proc-list", flexGrow = 1, gap = 2, align = "stretch" }, itemRows) end local refreshedAt = (st("refreshedAtMs") or 0) / 1000 From 68eab2c3dc1315fbc1d4a25ea368f305a18ea3a0 Mon Sep 17 00:00:00 2001 From: Nguyen Tien Duy Date: Mon, 24 Aug 2026 16:31:32 +0700 Subject: [PATCH 5/5] Feat: Show summed RSS of filtered processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Σ RSS" sum to the panel footer. This total updates with any filter change and shows the sum of RSS for only matching processes. Also, add `Ctrl+K` to force-kill a selected process (SIGKILL). The `ps` calls for RSS sums exclude themselves via PID and command name to prevent self-matching. --- procmon/README.md | 7 +++-- procmon/panel.luau | 45 +++++++++++++++++++++++++++-- procmon/plugin.toml | 4 +-- procmon/service.luau | 56 ++++++++++++++++++++++++++++++++++++ procmon/translations/en.json | 4 ++- 5 files changed, 109 insertions(+), 7 deletions(-) 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",