diff --git a/procmon/README.md b/procmon/README.md index f6b8340..6be8334 100644 --- a/procmon/README.md +++ b/procmon/README.md @@ -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. diff --git a/procmon/panel.luau b/procmon/panel.luau index daf0e81..a4a3084 100644 --- a/procmon/panel.luau +++ b/procmon/panel.luau @@ -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 diff --git a/procmon/plugin.toml b/procmon/plugin.toml index e0d8f59..0b177b4 100644 --- a/procmon/plugin.toml +++ b/procmon/plugin.toml @@ -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" diff --git a/procmon/service.luau b/procmon/service.luau index 385af7b..6e6c3b7 100644 --- a/procmon/service.luau +++ b/procmon/service.luau @@ -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. diff --git a/procmon/translations/en.json b/procmon/translations/en.json index b4bfd70..b721708 100644 --- a/procmon/translations/en.json +++ b/procmon/translations/en.json @@ -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" } } diff --git a/procmon/widget.luau b/procmon/widget.luau index 3340d56..67f987a 100644 --- a/procmon/widget.luau +++ b/procmon/widget.luau @@ -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)