* Add Tailscale header on/off toggle * Address Tailscale toggle review feedback * Hide Tailscale tooltip while busy * Keep Tailscale labels clear of header toggle * Extract the switch from Toggle into a reusable ToggleSwitch Toggle rendered its own track and knob inline, so anything else wanting a switch had to copy the geometry. Pull it into Ui/ToggleSwitch.qml and let Toggle compose it, and give PanelHero a trailingControl slot so a hero can pin a control to its trailing edge without the caller doing the layout. The switch draws its cursor as a ring outside the track: themes give normal chrome a stronger border than hover-cursor, which is right for controls that are borderless at rest but would make a bordered track go fainter under the cursor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Keep the panel cursor on the virtual header section clampCursor resets focusSection whenever it is not in visibleSections, but "header" is virtual and never appears there. Any refresh of the underlying model therefore threw the cursor off the hero toggle: muting republishes the PipeWire snapshot, and toggling the Bluetooth adapter empties and refills the device lists. moveCursor already special-cases "header"; clampCursor now does too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Toggle panel heroes with a switch instead of the status icon The hero icon doubled as the on/off control, which was invisible as an affordance and made the icon carry two jobs at once. Give Tailscale, Dropbox, Bluetooth, Audio, and Network a ToggleSwitch on the trailing edge of the hero and leave the icon to report status. The switch is the header's only cursor target, so the keyboard reaches it the same way the mouse does. Dropping the icon's focus ring also drops heroRingPad, which lets each hero line up with the rows beneath it. Network's link detail moves inline after the name -- "Ethernet (2.5gbit)" -- since the pill crowded the switch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Make the network hero switch honestly a Wi-Fi switch The switch reads and writes Networking.wifiEnabled, but its tooltip claimed to turn "network" on and off whenever Ethernet was the active connection. A click asserted nothing; a switch asserts state, so on a wired machine with the radio off it sat there reading "off" beside a perfectly live Ethernet connection. Say Wi-Fi, and only offer the switch when there is a radio to switch. headerActionCount follows the same condition so the keyboard cannot reach a control that is not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop the header cursor claiming an absent switch The switch hides when the thing it toggles is unavailable -- no Tailscale CLI, no Dropbox CLI, no Wi-Fi radio -- but "header" stayed reachable, so the cursor could sit on a target that never rendered. The old clickable icon was always on screen, so there was always something to highlight. "header" stays navigable and Enter still no-ops safely; the cursor just stops claiming a spot that is not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Wrap the gallery's switch caption The caption sat unbounded inside the switch row. The gallery has a 560px minimum width, horizontal scrolling off, and clipping on, so at that size the end of the line was simply unreachable. Move it below the row and wrap it, the way every other description in the gallery already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: David Heinemeier Hansson <david@hey.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Omarchy shell
omarchy-shell is a single long-running Quickshell
instance that hosts the Omarchy desktop. Hyprland autostart launches one shell
per graphical session; everything else — the bar, background switcher, panels,
and overlays — runs inside the shell as a plugin.
Hosting everything inside one shell means:
- shared services and singletons live once, not once per process
- summoning a panel is an IPC call into a process that is already running,
not a fresh
quickshell -p ...cold start - third-party plugins can be loaded from disk without changing any source code in Omarchy itself
The runtime layout:
shell/
shell.qml entry point (ShellRoot)
services/
PluginRegistry.qml discovers, validates plugins, looks up enabled state in shell.json
BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p)
plugins/
bar/ first-party plugins (see plugins/README.md)
image-picker/
menu/
notifications/
panels/
audio/
bluetooth/
monitor/
network/
power/
weather/
model-usage/
services/
battery/
idle/
osd/
polkit/
The plugin discovery path is documented in plugins/README.md.
Plugin manifest
Every plugin ships a manifest.json describing what it is and how the
shell should load it. Minimal example:
{
"schemaVersion": 1,
"id": "my.org.cool-clock",
"name": "Cool clock",
"version": "1.0.0",
"author": "You",
"description": "A clock that does cool things",
"kinds": ["bar-widget"],
"entryPoints": { "barWidget": "Widget.qml" },
"barWidget": {
"displayName": "Cool clock",
"category": "Time",
"allowMultiple": false,
"defaults": { "format": "HH:mm" },
"schema": [
{ "key": "format", "type": "string", "label": "Format" }
]
}
}
Supported kinds:
| Kind | What it is |
|---|---|
bar-widget |
A component that the active bar can drop into a section |
panel |
A persistent or summoned floating window (e.g. OSD) |
overlay |
A fullscreen overlay (e.g. background switcher) |
menu |
A summoned menu surface |
service |
A headless singleton, no UI |
bar |
A full bar option that can replace the built-in omarchy.bar |
Only one bar plugin is active at a time. Missing or invalid selections fall
back to the built-in omarchy.bar, so users always have a safe path home.
Panels, overlays, and menus are loaded when summoned. Plugins that need
to outlive a single summon can set keepLoaded: true (e.g. the image
picker keeps its overlay window mounted between summons). First-party
services are loaded at startup.
The full schema lives in services/PluginRegistry.qml.
Installing a third-party plugin
A plugin is a git repo with a manifest.json at its root. Adding one
clones it straight into ~/.config/omarchy/plugins/<id>/ (named by the
manifest id); updating is a fast-forward pull of that checkout.
omarchy plugin add https://github.com/acme/omarchy-weather.git
omarchy plugin update acme.weather # fetches, shows a diff, fast-forwards
omarchy plugin update --all
omarchy plugin remove acme.weather
⚠️ Plugins run as unsandboxed code inside
omarchy-shell. Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. Only add repos whose code you are willing to run.
Each command is interactive when run bare in a terminal (gum pickers,
confirmation, a diff to review) and fully non-interactive when given
arguments. Pass --yes to skip every prompt — this is the path for scripts and
AI agents:
omarchy plugin add https://github.com/acme/omarchy-weather.git --enable --yes
omarchy plugin update --all --yes
The installer never runs plugin code, install hooks, or sudo — it only clones files, validates the manifest, and toggles enabled state over shell IPC. Since an installed plugin is a plain git checkout, anything beyond add/update (pinning a ref, switching branches) is ordinary git in the plugin directory.
Installing by hand
You can still drop a plugin in without git:
- Put it in
~/.config/omarchy/plugins/<plugin-id>/with amanifest.jsonplus the QML referenced from itsentryPoints. omarchy plugin rescan.omarchy plugin enable <id>(bar widgets also needomarchy bar plugin add <id>; full bar replacements are selected withomarchy bar use <id>).
The lower-level IPC equivalents remain available via omarchy-shell shell rescanPlugins,
omarchy-shell shell setPluginEnabled <id> true, and omarchy-shell shell listPlugins.
The omarchy plugin command wraps those calls and can also edit the persisted
bar layout in shell.json.
To hack on an existing widget safely, clone it into a user plugin instead of
editing the built-in source. Third-party ids must be namespaced and may not use
the reserved omarchy.* prefix.
omarchy plugin clone omarchy.clock local.clock --replace
omarchy plugin clone # interactive source/name picker
omarchy plugin edit local.clock # cd into the plugin directory
First-party plugins under shell/plugins/
are discovered the same way and cannot be disabled, except that the built-in
bar option can become inactive while a third-party kind: "bar" plugin is the
selected bar.
IPC contract
The shell exposes a single shell IPC target plus whatever extra targets
individual plugins register (e.g. the bar's bar target for refresh
hooks, the image picker's image-selector target). omarchy-menu uses the
shell target to summon the first-party omarchy.menu plugin instead of
running a separate Quickshell instance.
| Method | Returns | Effect |
|---|---|---|
ping |
ok |
health check |
summon <id> <payloadJson> |
ok / unknown |
load + open a panel/overlay plugin |
hide <id> |
— | close a previously-summoned plugin |
toggle <id> <payloadJson> |
— | summon if closed, hide if open |
call <id> <method> <arg> |
string | call a method on an already-loaded plugin |
rescanPlugins |
— | re-walk plugin dirs and hot-reload plugin code |
reloadConfig |
ok |
reload ~/.config/omarchy/shell.json |
setPluginEnabled <id> <enabled> |
ok / unknown |
flip the persisted enabled bit (see note) |
listPlugins |
JSON | every discovered plugin (id, name, kinds, enabled) |
Direct invocation:
quickshell ipc -p $OMARCHY_PATH/shell call shell ping
Hyprland autostart launches the shell directly with quickshell -p $OMARCHY_PATH/shell. Use omarchy-restart-shell to stop every running
instance of that config and launch one fresh shell process.
A convenience wrapper, omarchy-shell, forwards IPC
calls to the running shell. It does not start the shell.
omarchy-shell shell ping
omarchy-shell shell toggle omarchy.menu '{"menu":"root"}'
omarchy-shell shell listPlugins
omarchy-shell shell rescanPlugins
Note on setPluginEnabled: the enabled argument is a string. Only the
literal "true" enables the plugin; every other value (including "True",
"1", "yes", or omitted) disables it. This keeps the IPC surface
type-stable across QML's string-only IPC arguments.
Persisted state
There is one user config file. Everything that distinguishes your customization from the shipped defaults lives in it.
| Path | Owner | Purpose |
|---|---|---|
~/.config/omarchy/shell.json |
the shell | full layout + per-entry settings + enabled plugin list |
~/.config/omarchy/plugins/<id>/ |
user | drop-in third-party plugin source files |
The config/omarchy/shell.json default config describes the
fresh-install state. When the user has no shell.json, the shell uses
the defaults verbatim. Once the user customizes anything, shell.json
becomes the authoritative file — we do not deep-merge defaults back in.
shell.json shape
{
"version": 1,
"idle": {
"screensaver": 150,
"lock": 300
},
"bar": {
"id": "omarchy.bar",
"position": "top",
"transparent": false,
"centerAnchor": "omarchy.clock",
"layout": {
"left": [ { "id": "omarchy.menu" }, { "id": "omarchy.workspaces" } ],
"center": [ { "id": "omarchy.clock", "format": "HH:mm" } ],
"right": [
{ "id": "omarchy.audio" }
]
}
},
"plugins": []
}
Storage rules
- The active bar option is
bar.id. Omit it or set it toomarchy.barto use the built-in bar. Set it to another plugin id whose manifest declareskind: "bar"to replace the full bar. - Every plugin instance is one entry. Either in
bar.layout.<section>for bar widgets, or inplugins[]for panels, overlays, services, menus, and anything else non-bar. - Settings are inline on the entry. No
config:sub-object, no separate per-plugin settings file, no merge layers. The fields on each entry are the values the plugin sees. - Built-in widget ids are namespaced. Use ids such as
omarchy.clock,omarchy.audio, andomarchy.network. The migration rewrites older ids likeClockandAudioPanelforward. - Third-party enabled ⇔ present. A third-party plugin is enabled iff
its id appears somewhere in shell.json. For full bar options, that means
bar.id; for bar widgets,omarchy bar pluginadds/removes layout entries; other plugin kinds are enabled with the shell IPC. First-party non-bar plugins are always enabled. - Multiple instances are allowed when a manifest sets
allowMultiple: true. Each instance is independent — e.g. two clock widgets in different timezones are just two{"id":"omarchy.clock", "timezone": ...}entries with their own values. - Idle timings are top-level.
idle.screensaverandidle.lockare seconds since user idle began, so the default lock fires at 300s even if the 150s screensaver starts first. version: 1is required at the top level. The shell will fall back to defaults rather than load an unknown version.
Implementation history
Built up in phases on this branch:
- Phase 1 —
omarchy-shell phase 1: host the existing bar in a single shell - Phase 2 —
omarchy-shell phase 2: plugin registry and bar widget registry - Phase 3 —
omarchy-shell phase 3: fold bar-settings into the shell as a panel plugin - Phase 4 —
omarchy-shell phase 4: absorb background-switcher as a plugin - Phase 5 —
omarchy-shell phase 5: docs, cleanup, and migration crumbs - Phase 6 —
omarchy-shell phase 6: reviewer cleanup (path traversal, collision, races) - Phase 7 —
omarchy-shell phase 7: replace socket with IpcHandler, rename to image-picker - Phase 8a —
omarchy-shell phase 8a: unified shell.json with inline plugin settings
Shared services and Pipewire/UPower/Hyprland consolidation are explicitly out of scope here and deferred to a follow-up after a review pass.