
Founding engineer at yeet, working on kernel-side observability and the tooling around it. I write about eBPF, Linux internals, and why your telemetry bill looks the way it does.
Yes, an Omarchy bar widget can be written in JavaScript instead of QML, because the QML the shell loads does not have to contain your widget. It can be a fixed client that mirrors a tree described somewhere else. yeetkit-omarchy generates that client once, so
manifest.json,BarWidget.qmlandPanel.qmlare build output you never open, and the plugin you write is anapp/page.jsxSolidJS page running in a yeet isolate on the same machine. Seven view ops (mount insert remove text attr listen unlisten) travel from the isolate to the shell over that process's own pseudoterminal underscript(1), so there is no port, no socket and no Node hub. The official QML path at plugins.omarchy.org stays the right answer for a widget that is mostly decoration. JavaScript wins when the widget's job is to compute something out of live system data, because then the computing happens next to the data rather than in the shell process that has to stay responsive.
I build kernel-side instrumentation for Linux servers, and I have never shipped a desktop shell theme in my life. What I have watched, repeatedly, is the moment somebody wires a system readout into a status bar and discovers that the readout and the cost of producing it are the same object: the shell script that computes CPU percentage is a process, it wakes every second, and it appears in the process list it is printing. That is not a QML problem or a Waybar problem. It is a placement problem, and placement is the only thing this post is really about.
Seven routes get used for this, and they differ on one question that matters more than language: where does the arithmetic happen. A bar module that shells out to a script puts the arithmetic in a process that must be created, scheduled and reaped on every tick. A QML widget puts it inside the long-running shell process, where a slow read blocks the compositor's client. A subscription puts it in a separate isolate that is handed samples by a daemon already reading the kernel. Language follows from that choice more often than it drives it.
| tool | what you write | where the numbers come from | update model | what it needs |
|---|---|---|---|---|
proctop on yeetkit-omarchy | app/page.jsx, SolidJS | yeet.graph subscriptions inside a yeet isolate | pushed by the daemon at a requested interval, 1 Hz open, one sample every 4 s shut | yeet on PATH, yeetd running, script from util-linux |
| hand-written Omarchy shell plugin | manifest.json plus BarWidget.qml, Panel.qml | whatever you wire up inside the shell process | your own QML Timer | qmllint from qt6-declarative for validation |
| omarchy-vitals | nothing, it is installable | a Python 3 collector reading /proc/stat, /proc/meminfo, /sys/class/hwmon, nvidia-smi | polls on a configurable interval, 2 s by default | python3 |
Omarchy bar module, type: "command" | a shell script printing text or JSON | whatever the script reads | the interval in the bar config | nothing |
| Waybar custom module | a shell script | whatever the script reads | interval in seconds, minimum 0.001, or a real-time signal | Waybar instead of the Omarchy bar |
| dbar | a config file | its own built-in modules | one shared timer that wakes when the earliest module is due | replacing your bar, and Rust 1.89+ to build |
| btop in a floating terminal | nothing | /proc | update_ms, 2000 by default | a terminal window that is not the bar |
Two of these are not really competitors and it is worth saying so before the comparison starts. btop is the reference for what a dense terminal graph should look like, and if you are happy with a keybind that summons a terminal then you are done and this post has nothing for you. dbar is a whole bar rather than a widget, so choosing it means leaving the Omarchy shell, which is a much larger decision than the one this post is about. Both are listed because a reader weighing "should I write anything at all" deserves the honest answer that sometimes the answer is no.
proctop is a live CPU and memory monitor for the Omarchy bar: two braille history charts and a table of the ten largest processes, in a panel under the bar item. The bar item carries a braille sparkline and a percentage for CPU, the same pair for memory, and the 1, 5 and 15 minute load averages. Clicking it opens the panel. The whole plugin is one file, app/page.jsx, and the framework underneath it is yeetkit-omarchy, which describes itself as Omarchy shell plugins written as yeetkit apps.
The trick is stated in one sentence in the framework's idea section: an Omarchy plugin is QML loaded into the long-running shell process, and a yeetkit app is a Solid tree in an isolate whose every mutation leaves as one patch on its tty. The browser client in yeetkit applies those patches to a DOM. This package ships a second client that applies them to QML items instead. So there is QML in the plugin folder, and the build wrote all of it, and it is the same QML for every plugin built this way.
What you give up is real and specific: this only generates the bar-widget kind, so a standalone panel, overlay, menu or full bar plugin still needs entry files the package does not produce yet. What you get is that the code computing a per-process CPU share sits in the same process as the subscription feeding it, which is the entire argument of this post and the reason the rest of the sections exist.
This is the documented way and it should be your default. The Omarchy manual defines a plugin as "a directory with a manifest.json and some QML", and the develop guide sets out the manifest fields: schemaVersion: 1, an id, name and version, a kinds array, and an entryPoints object mapping each kind to its QML file. Six kinds exist. bar-widget maps to BarWidget.qml and is "a component the active bar can drop into a section"; panel is a floating window, overlay is fullscreen, menu is a summoned surface, service is "a headless singleton with no UI", and bar replaces the built-in bar entirely.
The guide is explicit that "QML is required" for entry point files, and it is right to be. QML is a good language for laying out a widget, it hot-reloads well, and Quickshell is built for exactly this: a toolkit for status bars, widgets and lockscreens using QtQuick, which "loads changes as soon as they're saved". If your widget shows a clock, a workspace indicator, a toggle or an icon that changes on a signal, stop reading and write the QML. Twenty lines of Text and a Timer is less machinery than anything below.
The reason to leave this path is narrow. QML runs in the shell process, and the shell process draws your desktop. Any work you do there competes with the compositor's client for the same event loop, which is fine for formatting a string and less fine for walking every process on the machine, differencing two counters per process and re-rendering four rows of a chart, once a second, forever.
omarchy-vitals is the closest thing to prior art and it deserves a fair reading, because it is a better tool than this comparison usually implies. It describes itself as "a lightweight Omarchy / Hyprland status-bar plugin for CPU, GPU, memory, disk, network, and temperatures", and specifically as a "native Quickshell widget, not a Waybar script". It covers more surface area than proctop does: GPU utilisation and temperature, disk capacity, network upload and download rates, and hwmon temperatures are all things proctop simply does not show.
Its architecture is BarWidget.qml and Panel.qml for the interface, plus a Python 3 collector that reads /proc/stat, /proc/meminfo, /sys/class/hwmon and /sys/class/drm, shells out to nvidia-smi for NVIDIA GPUs, and polls on a configurable interval defaulting to two seconds. It also already does the smartest thing in this category: process and directory information is gathered only while the panel is open. That is the same instinct as proctop's idle backoff, arrived at independently, and it is the strongest evidence that the cost of a bar monitor is a problem people actually hit.
Where the two designs part is what happens on the ticks. A poll is a pull: something wakes up, decides it is time, opens files, parses them and exits or sleeps. A subscription is a push: the daemon that is already reading the kernel sends a sample when it has one. If you want breadth today and do not care which of those you are running, install omarchy-vitals. The rest of this post is about why the second shape is worth the extra dependency when the widget is going to run for the life of the login session.
Both bars let you skip plugin authoring entirely and point a config entry at a script. The Omarchy bar's module README accepts two module types, "command" for shell-driven output that can be plain text or JSON, and "qml" for custom widgets loaded from ~/.config/omarchy/bar/modules/, where a QML module "should be an Item with implicitWidth and implicitHeight" and may declare bar, moduleName and settings properties that the bar injects. Waybar's custom module is the same idea with a different vocabulary: exec is "the path to the script, which should be executed", and interval is "the interval (in seconds) in which the information gets polled", minimum 0.001.
This is the fastest possible route to a number in your bar and there is no shame in it. Three lines of awk over /proc/loadavg on a five second interval is a completely reasonable thing to run, and it will be correct forever. The trouble starts at one second and at per-process granularity, which is the specific case the next section is about, because that is where the fork, the exec, the open, the parse and the exit stop being free relative to the work being measured.
dbar is a small event-driven Wayland status bar for Sway, SwayFX and Niri, written in Rust, and it is the only project in this comparison that publishes overhead numbers, which is why it is here. Its README states that on the author's machine the bar "holds about 20 MB resident, 5 MB of that its own heap, the rest fonts it has mapped, and costs about a fifth of one percent of a core to leave running", against 62 MB and 0.51% for swaybar with i3status-rs and 76 MB and 0.75% for Waybar. Its design note is the one worth stealing: "They share one timer, which wakes when the earliest is due, reads everything that has come due and redraws once, ten modules on one interval cost one wake-up between them."
btop is the other end. It is a full resource monitor for processor, memory, disks, network and processes, its graph_symbol setting takes braille, block or tty, and braille is the high-resolution option that needs a font carrying the glyphs. Its default update_ms is 2000, and the documentation recommends that value or above for better graph sample times. Everything in this post about braille rendering is an attempt to get a few rows of a panel to read the way btop's charts read, in a space roughly a tenth the size.
Stop asking for the number and start receiving it. In proctop there is no timer anywhere in the page: samples arrive over yeet.graph.subscribe, which asks the daemon for a field at an interval and then hands each sample to a callback as it is produced. The daemon is already reading the kernel, so the cost of your widget existing is the cost of the sample being delivered and rendered, not the cost of a process being created to go and fetch it. That is what "subscribe to kernel metrics instead of polling" buys, and it is the whole of the cost argument. There is no measured overhead figure for proctop to quote here, so treat this as a claim about mechanism rather than about a benchmark.
await yeet.graph.subscribe(
`subscription { kernel_stats(interval_ms: 1000) {
total { user_ms nice_ms system_ms idle_ms iowait_ms irq_ms softirq_ms steal_ms }
procs_running } }`,
(sample) => onKernelStats(sample.data ?? sample),
);
interval_ms is the rate you are asking the daemon to sample at, and it is a property of the subscription rather than of your code, which means you can change it without restarting anything. That is the second half of the trick. The heavy field here is procs, which carries every process with its stat on every tick and exists only to feed the panel's table. While the panel is shut, the only consumer of that field is the bar item's tooltip, which needs a process count and nothing else, so proctop unsubscribes and re-subscribes it at one sample every four seconds and restores 1 Hz on open.
const HZ = 1000; /* sample interval while the panel is open, ms */
const IDLE = 4000; /* and while it is closed: the bar only needs the count */
<panel onOpen={() => { setOpen(true); watchProcs(HZ); }}
onClose={() => { setOpen(false); watchProcs(IDLE); }}>
Four subscriptions run in total: procs at the variable rate, and meminfo { mem_total mem_available }, kernel_stats, and load_average { one five fifteen } at 1 Hz. Core count is a one-shot yeet.graph.query because it does not change. Splitting them is not a stylistic choice, and this is the one thing most likely to cost you an afternoon: one subscription per root field, because a subscription carrying several of them delivers only the first. The code comment says so plainly, and discovering it by experiment is unpleasant.
By making the QML a renderer rather than a widget. The isolate holds the Solid tree, and what crosses to the shell is a patch stream in the yeetkit wire format: seven view ops, mount insert remove text attr listen unlisten, with each node arriving as {id, tag, attrs, on, kids} or {id, text}. The QML client, qml/Yeetkit.qml, mirrors that tree by id, and for each tag it instantiates nodes/<Tag>.qml. Two details in that format are there because of bugs somebody already hit: false travels as a value rather than as a removal, so visible={false} means what it says, and attributes keep their JSX types, so gap={8} arrives as a number and lands on an int property.
Each node QML file satisfies three conventions and nothing more. It exposes plain properties named exactly as the JSX attributes are, so setAttr can assign them and coerce to the property's type, restoring a captured default when an attribute is removed. It exposes a slot Item that children are reparented into, or null if it is a leaf. And it exposes an ev(type, payload) signal for user actions, of which only the types the isolate has actually listened for are sent up. Text nodes have no item at all: a node's text property is the join of its text children, which is how <button>Save {n()}</button> patches one segment instead of rebuilding a button.
export default function Page() {
const [count, setCount] = createSignal(0);
return (
<>
<bar tooltipText="Processes">{count()} procs</bar>
<panel contentWidth={320} onOpen={() => start()} onClose={() => stop()}>
<header>Top by memory</header>
<Index each={top()}>{(p) => (
<row fill><text fill>{p().comm}</text><text tone="muted">{p().rss}</text></row>
)}</Index>
</panel>
</>
);
}
<bar> becomes the shell's own WidgetButton, so hover, tooltip, vertical bar layout and click registration belong to the host rather than to you. <panel> becomes a KeyboardPanel anchored to that bar item, opened by a left click and closed by Escape. Both can sit anywhere in the tree, a layout may wrap them, and a page with no <panel> is simply a widget that sends its clicks up. The rest of the vocabulary is column, row, text, icon, header, separator, spacer, box, scroll, button, toggle, slider, input and image, each mapping to a real Quickshell or Omarchy component with its attributes listed in the framework's vocabulary table.
One QML limitation leaks through and it is worth knowing before you build a long list. QML has no insertBefore, so an insert re-appends the tail after the anchor, which is linear in the length of the tail. For a panel with ten rows that is free. For a thousand-row virtualised list it would not be, and the framework says so rather than letting you find out.
Yes, and the mechanism is older than any of this. The transport is the isolate process's own stdio, wrapped in script from util-linux, whose man page describes it as making "a typescript of everything on your terminal session" and which works through "the slave end of the session's pseudoterminal". A pipe is not a terminal, and an isolate has a tty (the one lane with an input side) only when it is given a PTY, so yeet run app.js is launched under script: the isolate sees a terminal, its output arrives on stdout, and every byte written to its stdin is a keystroke. Frames go down inside an OSC sequence escaped to pure ASCII and messages come back as base64url ending in Enter. Frames of a megabyte cross intact.
The security property falls out of that for free, and it is the reason to prefer it over the obvious alternative of a localhost port. There is no port, no socket, no Node hub, and nothing another user on the machine can dial. A widget that listens on 127.0.0.1 is reachable by every process on the box; a widget whose only channel is the stdin of a child it spawned is reachable by its parent. For a desktop plugin that reads every process on the machine, that difference is not academic.
Process lifetime is handled one level up. Isolate.qml is a QML singleton, and the shell is one QML engine, so however many monitors show the widget there is exactly one yeet run, started when the first widget attaches and stopped a few seconds after the last one detaches. Two monitors showing the bar is two views of one isolate, the same way two browser tabs are in yeetkit. That also means the idle backoff in the previous section is a property of the one process, not of each copy of the widget.
Divide by the wall time you measured, never by the interval you asked for. Field one of /proc/PID/schedstat is documented by the kernel's scheduler statistics page as "time spent on the cpu (in nanoseconds)", cumulative over the life of the process, so a process's share of one core over a window is the delta of sum_exec_runtime across that window divided by the window's length in the same units. The counter itself, and what field two buys you, is the subject of an earlier post on finding which process is slowing down a Linux machine, so the counter is not re-derived here.
What is different in a widget is that the window is not a constant you control. The interval flips between 1000 ms and 4000 ms whenever the user opens or closes the panel, a sample can be late, and dividing by HZ because that is what you asked for produces a figure that is quietly wrong by a factor of four for one tick after every open. Measuring the elapsed time is two extra lines and it removes the whole class of error.
const now = Date.now();
const runtime = new Map();
for (const proc of data.procs) {
if (proc.schedstat) runtime.set(proc.pid, proc.schedstat.sum_exec_runtime);
}
const elapsed = seen ? (now - seenAt) * 1e6 : 0; /* ms to ns */
for (const [pid, ns] of runtime) {
const before = seen.get(pid);
if (before !== undefined) shares.set(pid, Math.max(0, (ns - before) / elapsed));
}
seen = runtime; seenAt = now;
Two smaller things in the same handler are worth copying. A process can go away between the moment the graph enumerates processes and the moment it reads that process's stat, and the graph reports that as a null stat rather than dropping the row, so the process count is every process while only the measured ones can be ranked or summed for num_threads. And the per-process history is kept in a plain Map rebuilt from scratch each tick, so a process that exits takes its sparkline history with it instead of accumulating dead pids for the length of the session.
Whole-machine CPU comes from a different field and the same principle. kernel_stats exposes cumulative jiffie counters, so utilisation for a tick is (busy now - busy before) / (total now - total before), where busy is user, nice, system, irq, softirq and steal, and total adds idle and iowait. load_average { one five fifteen } needs no differencing at all, which is why the bar item can show it from the first frame while the CPU percentage needs two samples before it means anything.
A braille cell is a 2 by 4 dot matrix, which is the whole reason this works: one line of N characters holds 2N samples, and stacking GH lines gives GH times 4 vertical levels. proctop uses four rows, so sixteen levels, and a panel 48 columns wide holds 96 samples of history in the space four lines of text would take anyway. The Braille Patterns block runs U+2800 to U+28FF, with U+2800 being the blank pattern, and each dot maps to one bit: dot 1 is 0x01, dot 2 is 0x02, dot 3 is 0x04, dot 4 is 0x08, dot 5 is 0x10, dot 6 is 0x20, dot 7 is 0x40, dot 8 is 0x80. Add the bits for the raised dots to 0x2800 and you have the character.
The dot numbering is historically irregular and this is where implementations go wrong. The left column, top to bottom, is dots 1, 2, 3, 7, because the lower two dots were added to a six-dot cell later. The right column is 4, 5, 6, 8. So the two lookup tables are not consecutive ranges:
const GH = 4;
const LEVELS = GH * 4;
const LEFT = [0x01, 0x02, 0x04, 0x40]; /* dots 1,2,3,7 top to bottom */
const RIGHT = [0x08, 0x10, 0x20, 0x80]; /* dots 4,5,6,8 */
for (let r = 0; r < GH; r++) {
let line = "";
for (let x = 0; x < width; x++) {
let bits = 0;
for (let col = 0; col < 2; col++) {
const value = samples[x * 2 + col];
if (value === undefined) continue;
const fill = Math.max(1, Math.round(((value - lo) / (hi - lo)) * LEVELS));
const dots = col === 0 ? LEFT : RIGHT;
for (let k = 0; k < 4; k++) {
const depth = r * 4 + k; /* levels down from the top */
if (LEVELS - depth <= fill) bits |= dots[k];
}
}
line += String.fromCharCode(0x2800 + bits);
}
}
The published recipes stop one step before this. The Rosetta Code sparkline task is the canonical minimal version and it uses the eight block characters U+2581 through U+2588, one per value, with no braille and no framing decisions at all. termcn's sparkline is an inline Unicode braille sparkline chart with four props, data, width, color and label, which is the right API for a library and leaves every decision below to its defaults. The mechanics are not the hard part. The next section is the hard part.
Note also that fill is filled downward from the top rather than traced as a line. Bars rising from a baseline are what give the per-process columns their level-meter look, and a traced line across four rows of braille is mostly indistinguishable from noise at this size. The floor of one level in that Math.max is doing real work too: without it, a CPU series sitting near zero between spikes draws nothing at all, so the chart reads as broken rather than as quiet.
The axis is shared when it should not be. CPU and memory look like the same kind of series and they are not, and giving both a 0-to-peak or 0-to-100% axis produces exactly those two artefacts. CPU is spiky, it uses the full range, and its height means something on its own, so an absolute 0 to 100% axis is right and a relative one would be a lie. Memory on a desktop sits in a narrow band, often 40% to 55% for hours, and on a 0-to-peak axis that draws as a solid block with a flat top which tells you nothing except that memory exists.
The fix is a min-max band computed over the visible window, with a floor on the span so that sampling noise cannot expand to fill the frame, and the band printed in the chart header so the reader knows what they are looking at:
const memRange = () => {
const past = memPast().slice(-samples());
if (!past.length) return { lo: 0, hi: 0.05 };
const lo = Math.min.apply(null, past);
const hi = Math.max.apply(null, past);
const span = Math.max(0.03, (hi - lo) * 1.3); /* floor, then 30% headroom */
const mid = (lo + hi) / 2;
return { lo: Math.max(0, mid - span / 2), hi: Math.min(1, mid + span / 2) };
};
Three further decisions are invisible until you get them wrong. Sparklines are right-aligned, because the newest sample belongs in the last cell: a series with less history than the chart is wide should fill from the right edge leftwards rather than starting at the left and stranding the newest reading in the middle. The history buffer is seeded by filling it with the first sample, so the chart is full width from the first frame and scrolls from then on instead of creeping in from the right for two minutes. And the window label counts real samples seen rather than buffer length, so it does not claim history that is only the first reading held flat.
Finally, the layout is a character grid, and the column count is measured rather than guessed. The shell's font is monospace, so every aligned line (chart header, braille graph, table row) is padded to the same number of columns, which makes their edges line up and fills the panel exactly. That count depends on the user's font size, so the panel measures it from real font metrics and reports it back through an onCols event. The table then divides what is left: both sparklines, the RSS figure and the live CPU figure are fixed-width, and the process name takes the remainder and elides.
Name a role, never a colour. Every node in the framework borrows the shell's theme through qs.Commons.Color and qs.Commons.Style, so tone takes one of fg, muted, accent, urgent or bar, and size takes a Style.font token from caption, bodySmall, body, subtitle, title, heading, display and displayLarge. No colour literal appears anywhere in proctop's page, which is why the same plugin looks correct on a blue theme, an orange theme and a monochrome one.
Charts need a continuous ramp rather than five named tones, so proctop passes a heat value between 0 and 1 that walks muted to accent to urgent. Two adjustments to that ramp are worth stealing because both came from a chart that looked wrong. Bars never dim: the scale starts at the accent and only climbs toward urgent, since height already carries the value and dimming a short bar makes it disappear. And the chart's vertical gradient is floored well above zero, so the bottom row never lands on muted and fades into the background.
There are two honest limits here and both belong next to the decision rather than in a footnote. On a deliberately monochrome theme there is no hue to follow, so the charts render in greys, which is correct behaviour but is worth knowing before you file a bug. And the bar item is drawn by the host as a single Text, so the braille and the percentage beside it cannot differ in colour; proctop tracks whichever of CPU and memory pressure is higher and colours the whole label with it. That is also why the load average figure in the panel footer is plain foreground with an urgent switch once load passes the core count, rather than a gradient: on a theme whose accent sits near the background, every point on the ramp below urgent reads as dimmed.
Four things, and the framework states all four in its own limits section rather than making you discover them. "use client" fails the build, because there is no browser. "use server" fails the build, because there is no Node hub; the isolate has yeet.graph, yeet:bpf and yeet:ai, and anything else means shelling out from a "use yeet" function or editing the generated QML. Streams from the shell are not supported, since a page consumes a "use yeet" generator directly in-process instead. And only the bar-widget kind is generated, so panel, overlay, menu and bar as standalone kinds need entry files the package does not write yet.
Write QML instead when any of these is true, and none of them is a close call:
bar-widget. A lockscreen overlay, a standalone panel, a full bar replacement. Follow plugins.omarchy.org and write the entry file.proctop needs yeet on PATH with yeetd running and yeet login completed, plus script from util-linux. A pure QML plugin needs the shell that is already running.There is also a verification boundary that should not be buried. The framework's status section records that the build, the wire against a real isolate over stdio under script, and the QML client, vocabulary and generated entry files have been verified under qml6 with doubles of the shell's components, on a machine without Omarchy, and that the one thing not yet verified is all of it inside a running Quattro shell where the real WidgetButton, KeyboardPanel and layer shell behave as themselves. The test/stubs/ doubles mirror the properties the code uses from the real shell/Ui and shell/Commons, and where the real components differ, the nodes and entry files are what to fix.
Usually nothing, and knowing which case you are in saves a lot of omarchy restart shell. The shell reloads plugin code on its own when files in the plugin directory change, and Isolate.qml watches app.js specifically: a rewritten bundle restarts the yeet run, the widget's client reconnects and is handed a fresh tree. So a change to your page is live as soon as the build writes it. A change to the generated QML entry files is the exception and does need omarchy restart shell, which in practice means you only restart the shell when you change the framework or regenerate the plugin scaffold.
git clone [email protected]:yeet-src/yeetkit-omarchy.git
cd yeetkit-omarchy && npm install
npm i -g --prefix ~/.local . # puts yeetkit-omarchy on PATH
yeetkit-omarchy new procs --id io.github.you.procs
cd procs && npm install # links the framework, about a second
npm run dev # builds into ~/.config/omarchy/plugins/io.github.you.procs
omarchy plugin enable io.github.you.procs
npm run dev builds into the shell's plugin directory and rebuilds on every change, which is the loop you want open in a second terminal. When a change does not show up and you cannot tell why, the isolate's console output lands in the shell's log, reachable with qs log -p "$OMARCHY_PATH/shell". That is the first place to look, because a thrown exception inside the page stops patches from arriving and looks exactly like a stale widget from the outside.
Two build details will confuse you once each. yeetkit-omarchy check drives a built plugin over a real portal and asserts four layers, but it also wants qml6 from qt6-declarative for the QML half; without it that half is skipped and the output says so rather than passing silently. And omarchy plugin validate . passes on a fresh clone, which is what omarchy plugin add produces, while in a working tree it fails on node_modules/.bin/yeetkit-omarchy, because npm always links a package's declared binary and Omarchy allows no symlinks inside a plugin folder. node_modules/ is gitignored and never ships, so that failure only ever affects a tree you have installed into.
If your widget shows something the shell already knows, write QML and follow plugins.omarchy.org. That path is documented, it is what every other plugin does, it has no runtime dependencies, and manifest.json plus a twenty-line BarWidget.qml is less machinery than anything else here. If you want live system metrics in the bar today and do not want to write anything, install omarchy-vitals, which covers GPU, disk, network and temperatures that proctop does not touch. If you want the reading without the bar at all, btop on a keybind is still excellent and needs no plugin.
Reach for yeetkit-omarchy when the widget has to compute something. Differencing schedstat.sum_exec_runtime across every process on the machine once a second, ranking the result, and folding it into sixteen levels of braille is arithmetic, and arithmetic belongs next to the data rather than in the process that draws your desktop or in a shell script that has to be created and destroyed to do it. proctop is that case worked all the way through, and it installs in one line:
curl -fsSL https://yeet.cx | sh
yeet login
omarchy plugin add https://github.com/yeet-src/omarchy-proctop --enable
See your machine the way the kernel sees it.
No. The yeet daemon handles the privileged load, and yeet run is unprivileged. A sudo yeet run in an example is a bug in the example. What the plugin does need is yeet on PATH with yeetd running and yeet login completed, plus script from util-linux, which every Arch install already has.
One. Isolate.qml is a QML singleton and the shell is a single QML engine, so there is one yeet run however many bar widgets attach to it. It starts when the first widget attaches and stops a few seconds after the last one detaches, and each widget is a view of the same tree.
Because a subscription carries one root field. Asking for procs and meminfo in the same subscription delivers only procs. Split them into one subscription per root field and collect the tickets, remembering that each ticket arrives as a promise and unsubscribe wants the resolved string.
No. There is no browser and no DOM, so there is no stylesheet. Styling is the tone, size, bold, fill, align and heat attributes on the vocabulary's nodes, which resolve against the active Omarchy theme. That is a real constraint and it is also why a plugin follows the user's theme without you doing anything.
Because a process can exit between the moment the graph enumerates it and the moment it reads that process's stat, and the graph reports that as a null stat rather than dropping the row. The count includes those processes; the table, the thread total and the CPU ranking only include the ones with a stat.
Not as written. The framework targets the Omarchy Quattro shell specifically, and the QML client instantiates components from qs.Ui and qs.Commons, which are that shell's. Waybar has no QML plugin surface at all; its extension point is a custom module running a script on an interval.
The charts render in greys. Colour comes from the theme's muted, accent and urgent roles, and on a deliberately monochrome theme those carry no hue to follow, so the ramp collapses to lightness. Nothing breaks, and the height of a bar still carries its value.
No to both, in different directions. btop shows more, in a full terminal, with keyboard navigation and process control. A monitoring stack does retention, aggregation across machines and a query language, and proctop is one panel on one desktop with 400 samples of memory and a table of ten rows. It is a glanceable readout, not a system of record.
manifest.json fields schemaVersion: 1, id, name, version, kinds and entryPoints; maps each of the six kinds to its entry file, bar-widget to BarWidget.qml, panel to Panel.qml, overlay, menu, service and bar likewise; states that "QML is required" for entry point files; documents omarchy plugin validate, omarchy plugin list --json and omarchy-shell shell rescanPlugins.manifest.json and some QML"; describes bar-widget as "a component the active bar can drop into a section" and service as "a headless singleton with no UI"; documents omarchy plugin add <url> --enable, omarchy plugin remove <id>, enable, disable, list and clone.Item with implicitWidth and implicitHeight", may declare injected bar, moduleName and settings properties, and the schema accepts "command" modules driven by a shell script emitting text or JSON alongside "qml" modules loaded from ~/.config/omarchy/bar/modules/./proc/<pid>/schedstat as "time spent on the cpu (in nanoseconds)", "time spent waiting on a runqueue (in nanoseconds)" and "# of timeslices run on this cpu"; field one is the cumulative counter whose delta over a measured window gives a process's share of one core.data, width, color and label; the library answer a reader is usually pointed at, and the reason framing decisions end up as defaults rather than choices.graph_symbol setting takes braille, block or tty, braille being the high-resolution option subject to font availability; default update_ms is 2000, and the documentation recommends that value or above for better graph sample times.BarWidget.qml and Panel.qml over a Python 3 collector polling at a configurable interval, two seconds by default, reading /proc/stat, /proc/meminfo, /sys/class/hwmon and /sys/class/drm, calling nvidia-smi for NVIDIA GPUs, and gathering process information only while the panel is open.exec is "the path to the script, which should be executed" and interval is "the interval (in seconds) in which the information gets polled", with a minimum of 0.001; a real-time signal between SIGRTMIN+1 and SIGRTMAX can replace the interval, and exec-on-event re-runs the script after a click or scroll.app/page.jsx, and what npm run dist, npm run check and npm run vendor each do."use yeet" modules are documented.schedstat counter read from the command line, including what field two tells you that field one cannot.Built with yeet, a JS runtime for writing eBPF programs on Linux machines. Join us on discord.