Capture this laptop's Omarchy customizations
Everything on top of a stock Omarchy 4.0.4-1 install, gathered so a second machine can be brought to the same state: the ~/.config files that actually differ from the shipped defaults, the three hand-built themes, the ten shell plugins pinned to commits, the two custom scripts, two user units, and the /etc drop-ins. install.sh applies it all idempotently, backing up anything it overwrites. capture.sh runs the other way so the repo stays the source of truth rather than a snapshot that rots. indie.herdr-hud's ten local-only commits ship as a git bundle plus a working-tree patch, since they stay off the upstream PR deliberately. Secrets, the dotfiles repo's territory (shell/git/mise), the remote-access stack and aether-managed themes are all scoped out; README says why for each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -0,0 +1,3 @@
|
||||
*.bak.*
|
||||
*.prefix.*
|
||||
*.new
|
||||
@@ -0,0 +1,146 @@
|
||||
# omarchy-setup
|
||||
|
||||
Every customization this laptop has on top of a stock Omarchy install, in one
|
||||
place, so a second machine can be brought to the same state.
|
||||
|
||||
Captured from Omarchy **4.0.4-1** on **2026-10-10**.
|
||||
|
||||
```bash
|
||||
git clone <this repo> ~/omarchy-setup
|
||||
cd ~/omarchy-setup
|
||||
./install.sh --dry-run # see what it would change
|
||||
./install.sh # do it
|
||||
```
|
||||
|
||||
`install.sh` is idempotent and backs up anything it overwrites as
|
||||
`<file>.bak.<epoch>`. `capture.sh` runs the other way, pulling live changes back
|
||||
into the repo so this stays the source of truth.
|
||||
|
||||
## What is in here
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `config/` | Mirrors `~/.config`. Only files that differ from Omarchy's shipped defaults. |
|
||||
| `themes/` | The three hand-built themes, wallpapers included. |
|
||||
| `plugins.txt` | The 10 Omarchy shell plugins, each pinned to a commit. |
|
||||
| `plugin-patches/` | Local plugin work that exists on no remote (see below). |
|
||||
| `bin/` | `omarchy-handoff` and `fix-stuck-displays`. |
|
||||
| `systemd/user/` | `lan-mouse.service`, `voxtype.service`. |
|
||||
| `system/` | `/etc` drop-ins: the ARP sysctl and `hid_apple` fnmode. |
|
||||
| `packages.txt` | Packages beyond the Omarchy baseline, each with a reason. |
|
||||
|
||||
## The customizations, and why
|
||||
|
||||
**Displays** (`config/hypr/monitors.lua`) are the fiddliest part. The file is the
|
||||
single source of truth for monitor rules on purpose — overlapping rules spread
|
||||
across several files is what previously made external displays unrecoverable.
|
||||
Three outputs are named by EDID description rather than connector, so they match
|
||||
whichever port they land on: the built-in Chimei panel at 1.25x, the LG IPS236 on
|
||||
HDMI at 1x, and the LG TV through the Anker dock. The TV is deliberately left on
|
||||
`preferred` (3840x2160@30) — the dock negotiates DP alt-mode with only two lanes,
|
||||
so forcing 4K60 produced a "monitor has no mode" loop. `GDK_SCALE=1` with a 1.25
|
||||
monitor scale; the stock pairing is 2 and `auto`.
|
||||
|
||||
`hyprland.lua` sources `hyprmoncfg-monitors.lua` last so the `hyprmoncfg` daemon's
|
||||
generated layout wins over anything above it.
|
||||
|
||||
`bin/fix-stuck-displays` recovers the related kernel-side failure: after
|
||||
suspend/resume i915 can leave an unplugged connector "disconnected" but still
|
||||
"enabled" on a pipe Hyprland has forgotten, which blocks every other output from
|
||||
modesetting. It forces the connector on, disables it through Hyprland to release
|
||||
the pipe, then returns it to auto-detect. It needs passwordless sudo for
|
||||
`tee /sys/class/drm/*/status`.
|
||||
|
||||
**Keyboard/mouse sharing** (`bin/omarchy-handoff`, `systemd/user/lan-mouse.service`)
|
||||
drives `lan-mouse` to share input with the other Omarchy laptop, bound to
|
||||
`SUPER+SHIFT+K` (`SUPER+K` is already Keybindings). The script's header comment
|
||||
is the real documentation — it records the non-obvious facts that cost hours:
|
||||
`capture_backend` must be `layer-shell` (the portal backend is a silent no-op on
|
||||
Hyprland 0.56.2), `[authorized_fingerprints]` is keyed by fingerprint and the
|
||||
reversed form fails silently, and the log variable is `LAN_MOUSE_LOG_LEVEL`, not
|
||||
`RUST_LOG`. It runs as a user unit rather than from `autostart.lua` because
|
||||
`o.launch_on_start` sends both streams to `/dev/null`, hiding every pairing
|
||||
failure.
|
||||
|
||||
`system/sysctl.d/99-omarchy-handoff-arp.conf` goes with it: Wi-Fi and the dock's
|
||||
ethernet both sit on `192.168.1.0/24`, and with the default ARP settings the
|
||||
subnet's mapping collapses onto whichever NIC replied last.
|
||||
|
||||
**Bar and shell** (`config/omarchy/shell.json`) adds `omarchy.active-window` to
|
||||
the left, five plugin widgets, and `indie.herdr-hud` as a non-bar plugin. Idle is
|
||||
stretched a long way past stock — screensaver at 20 minutes, lock at 30, against
|
||||
2.5 and 5.
|
||||
|
||||
**Keybindings** (`config/hypr/bindings.lua`) rebinds `SUPER+SHIFT+A` from the
|
||||
stock ChatGPT webapp to Claude, and adds four Herdr HUD bindings plus Handoff.
|
||||
|
||||
**Input** (`config/hypr/input.lua`) enables the three-finger horizontal
|
||||
workspace gesture that ships commented out, and flattens pointer acceleration for
|
||||
`unknown-device*`. That second rule belongs to Handoff: lan-mouse forwards
|
||||
relative deltas the *sending* compositor has already accelerated, and since
|
||||
Omarchy leaves `accel_profile` adaptive the receiver amplifies them a second time,
|
||||
so a forwarded pointer arrives hypersensitive while the local trackpad feels
|
||||
normal. `wlr-virtual-pointer` gives Hyprland no device name — virtual keyboards
|
||||
get named, pointers don't — hence matching on `unknown-device`; no real pointer on
|
||||
this machine uses that name, so the rule cannot touch the trackpad.
|
||||
|
||||
**Terminals** are all dropped to a smaller font (9 -> 8) except ghostty, which
|
||||
went the other way to 12. `shell.toml` sets the bar font base size to 10.
|
||||
|
||||
**Branding** (`config/omarchy/branding/`) replaces the about and screensaver
|
||||
ASCII art.
|
||||
|
||||
**`hid_apple` fnmode=2** makes the F-keys act as F-keys on an Apple keyboard.
|
||||
It only takes effect after `sudo mkinitcpio -P` or a module reload.
|
||||
|
||||
## Local plugin work
|
||||
|
||||
`indie.herdr-hud` is the one piece that exists on no remote. Ten commits live on
|
||||
its `local` branch — touchpad scroll feel, agent ordering, context-size display —
|
||||
and they stay off the upstream PR deliberately. `plugin-patches/` carries them as
|
||||
a git bundle plus a patch for the uncommitted working tree, and `install.sh`
|
||||
replays both after cloning. `io.github.tallsam.navbar-cat` has a one-file working
|
||||
tree change captured the same way.
|
||||
|
||||
`capture.sh plugins` re-bundles and re-diffs these, so local work keeps flowing
|
||||
back into the repo.
|
||||
|
||||
## Not in here, on purpose
|
||||
|
||||
- **Secrets.** `~/.config/lan-mouse/` holds a private key and per-machine
|
||||
fingerprints; `~/.config/novnc/auth.env`, SSH keys and the git credential
|
||||
helpers are likewise machine-local. Pair a new machine with
|
||||
`omarchy-handoff peer <host> <ip>` and `omarchy-handoff doctor`.
|
||||
- **Shell, git and dev tooling.** That is already its own repo, `dotfiles`
|
||||
(`git.nic-oconnor.com/public/dotfiles`), which handles zsh, mise, 1Password and
|
||||
the `~/.local/bin` tool shims. The only overlap avoided here is
|
||||
`~/.config/git/config`, whose customizations are identity and credential
|
||||
helpers — `dotfiles` territory.
|
||||
- **The remote-access stack.** `wayvnc`, `novnc`, `sunshine`, `freerdp`, `remmina`
|
||||
and `omarchy-remote-desktop` are installed on this laptop but scoped out.
|
||||
`kokd.remote-desktop` is still in `plugins.txt` since it is a bar plugin, but
|
||||
it is not placed in `shell.json`.
|
||||
- **Work tooling.** The keycloak helpers (`kcadm`, `kclogin`, `wren-keycloak`) and
|
||||
`paperclipai.service`.
|
||||
- **aether-managed themes.** `pink-gt3rs`, `porsche-cayman` and `porsche-rain`
|
||||
carry a `.aether-managed` marker and are reinstallable with `aether`, so their
|
||||
~18MB of wallpapers and previews stay out.
|
||||
- **`btop.conf`.** Its only diff against stock is v1.4.7 default drift, not a
|
||||
real customization.
|
||||
- **Backup files.** Fifteen `.bak.*` and `.prefix.*` files are sitting in
|
||||
`~/.config/hypr` and `~/.config/omarchy` from earlier editing sessions. They are
|
||||
not tracked and are safe to delete.
|
||||
|
||||
## Adding a file
|
||||
|
||||
`capture.sh` only refreshes what the repo already tracks, so start it by hand:
|
||||
|
||||
```bash
|
||||
cp ~/.config/foo/bar.conf config/foo/bar.conf # path mirrors ~/.config
|
||||
git add config/foo/bar.conf
|
||||
```
|
||||
|
||||
`install.sh config` and `capture.sh config` both walk `config/` recursively, so
|
||||
nothing else needs changing. Check first that the file actually differs from
|
||||
stock — `diff /usr/share/omarchy/config/foo/bar.conf ~/.config/foo/bar.conf` —
|
||||
or you will be tracking a file Omarchy already ships.
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Release display pipes the kernel left enabled on unplugged connectors.
|
||||
#
|
||||
# After suspend/resume (or unplugging a USB-C display) i915 can leave a
|
||||
# connector "disconnected" but still "enabled" on a pipe that Hyprland has
|
||||
# forgotten. That pipe then blocks every other external output from
|
||||
# modesetting (black screens, or a ~5 s re-modeset loop).
|
||||
#
|
||||
# Fix: force the connector on so Hyprland sees it, disable it from Hyprland
|
||||
# (which releases the pipe), force it off, then return it to auto-detect.
|
||||
# Never `hyprctl reload` here: a reload re-enables the connector while it is
|
||||
# forced on and re-sticks the pipe.
|
||||
|
||||
log() { echo "fix-stuck-displays: $*"; logger -t fix-stuck-displays -- "$*"; }
|
||||
|
||||
wait_for() { # wait_for <seconds> <command...>
|
||||
local n=$(( $1 * 4 )); shift
|
||||
for _ in $(seq "$n"); do "$@" && return 0; sleep 0.25; done
|
||||
return 1
|
||||
}
|
||||
|
||||
stuck=()
|
||||
for d in /sys/class/drm/card*-*; do
|
||||
[[ -f $d/status && -f $d/enabled ]] || continue
|
||||
[[ $d == *eDP* ]] && continue
|
||||
[[ $(<"$d/status") == disconnected && $(<"$d/enabled") == enabled ]] && stuck+=("$d")
|
||||
done
|
||||
|
||||
if (( ${#stuck[@]} == 0 )); then
|
||||
log "no stuck connectors"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
systemctl --user stop hyprmoncfgd
|
||||
|
||||
for d in "${stuck[@]}"; do
|
||||
out=${d##*/card*-}
|
||||
log "releasing $out"
|
||||
echo on | sudo -n tee "$d/status" >/dev/null
|
||||
wait_for 5 sh -c "hyprctl monitors all | grep -q '^Monitor $out '" || log "$out never appeared in Hyprland"
|
||||
hyprctl eval "hl.monitor({ output = \"$out\", disabled = true })" >/dev/null
|
||||
wait_for 5 grep -qx disabled "$d/enabled" || log "$out still enabled after disable"
|
||||
echo off | sudo -n tee "$d/status" >/dev/null
|
||||
sleep 1
|
||||
echo detect | sudo -n tee "$d/status" >/dev/null
|
||||
log "$out now $(<"$d/status") $(<"$d/enabled")"
|
||||
done
|
||||
|
||||
# Drop any low-refresh fallback hyprmoncfgd wrote while outputs were failing.
|
||||
rm -f ~/.config/hyprmoncfg/display-fallbacks.json
|
||||
|
||||
# hyprmoncfgd re-applies the matching profile (modes, positions) on startup.
|
||||
systemctl --user start hyprmoncfgd
|
||||
@@ -0,0 +1,505 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Share keyboard and mouse with a nearby Omarchy machine
|
||||
# omarchy:group=handoff
|
||||
# omarchy:args=[status|toggle|on|off|position <edge>|peer <host> <ip>|fingerprint|pair <desc> <sha256>|restart|log [-f|N]|fixcase|doctor]
|
||||
# omarchy:examples=omarchy-handoff position right
|
||||
# omarchy:examples=omarchy-handoff toggle
|
||||
|
||||
# NOTE: the omarchy: metadata above is cosmetic. Omarchy's dispatcher only scans
|
||||
# its own bin dir, so `omarchy handoff ...` does NOT work -- invoke this as the
|
||||
# bare `omarchy-handoff`.
|
||||
|
||||
# HARD-WON FACTS -- kept here because lan-mouse rewrites config.toml on every
|
||||
# CLI activate/deactivate (i.e. every SUPER+SHIFT+K) and strips its comments.
|
||||
# Startup activation does NOT rewrite it; only the CLI path does.
|
||||
# * capture_backend MUST be "layer-shell". On Hyprland 0.56.2 the
|
||||
# "input-capture-portal" backend is a SILENT NO-OP: it initializes cleanly,
|
||||
# org.freedesktop.portal.InputCapture answers on D-Bus (v1, caps=3), and it
|
||||
# creates ZERO barriers. No edge ever fires and nothing is logged.
|
||||
# Verify with: lan-mouse --capture-backend <b> test-capture
|
||||
# and look for "adding capture for position X - using outputs: [NON-EMPTY]".
|
||||
# Note layer-shell logs an EMPTY pass for all positions first, then a
|
||||
# populated pass after enumerating outputs -- empty alone is not a failure.
|
||||
# * layer-shell arms barriers PER OUTPUT, not just the desktop's outer rim,
|
||||
# so an interior monitor edge is a valid crossing point.
|
||||
# * [authorized_fingerprints] is keyed by FINGERPRINT: "<sha256>" = "<desc>".
|
||||
# Reversed is a valid non-empty table, so paired() below CANNOT detect it;
|
||||
# auth then fails silently and looks exactly like a dropped packet.
|
||||
# * capture_backend/position/activate_on_startup/fingerprints all survive the
|
||||
# rewrite; only comments are lost.
|
||||
# * The daemon runs as the lan-mouse.service USER UNIT. Do NOT start it from
|
||||
# autostart.lua's o.launch_on_start(): that sends stdout AND stderr to
|
||||
# /dev/null, so every pairing failure is invisible. Cost: hours of
|
||||
# diagnosing from a stale pre-reboot log file while the live daemon logged
|
||||
# nowhere. `omarchy-handoff log` reads the journal for this reason.
|
||||
# * The log level env var is LAN_MOUSE_LOG_LEVEL, NOT RUST_LOG. RUST_LOG is
|
||||
# silently ignored, so it looks like debug logging "doesn't work".
|
||||
# * Transport is DTLS over UDP (webrtc-dtls), not QUIC. The error
|
||||
# "Alert is Fatal or Close Notify" (= ErrAlertFatalOrClose) means the REMOTE
|
||||
# sent a fatal alert, i.e. the PEER REJECTED OUR CERT. Something answered,
|
||||
# so it is a TRUST failure, never a firewall. Contrast "Connection timed
|
||||
# out" = nothing answered at all (firewall, or the source-address trap
|
||||
# below).
|
||||
# * The fingerprint lan-mouse compares is sha256 of the DER certificate --
|
||||
# exactly `openssl x509 -noout -fingerprint -sha256`. Not the PEM file hash,
|
||||
# not the pubkey hash.
|
||||
# * SOURCE-ADDRESS TRAP (two NICs on one subnet): the daemon listens on
|
||||
# 0.0.0.0 but does not set IP_PKTINFO, so replies take the kernel's
|
||||
# route-chosen source. With the dock up, `ip route get <peer>` prefers the
|
||||
# dock NIC, so a peer connecting to our Wi-Fi address gets replies from the
|
||||
# dock address and drops them as an unknown 4-tuple -> "Connection timed
|
||||
# out" on that address only, while ICMP to it works fine. Undocked it
|
||||
# inverts and works. Listing both peer-side IPs is correct; the unusable
|
||||
# one just costs a 5s timeout per attempt.
|
||||
|
||||
# Thin wrapper over lan-mouse. lan-mouse's own config.toml is the single source
|
||||
# of truth -- this script only drives its CLI so the edge and pairing are
|
||||
# changeable without hand-editing TOML, and so a keybind has something to call.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="${CONFIG:-$HOME/.config/lan-mouse/config.toml}"
|
||||
CERT="$HOME/.config/lan-mouse/lan-mouse.pem"
|
||||
SOCKET="${XDG_RUNTIME_DIR:-/run/user/$UID}/lan-mouse-socket.sock"
|
||||
LOG="${XDG_STATE_HOME:-$HOME/.local/state}/lan-mouse.log"
|
||||
|
||||
notify() { omarchy-notification-send -g "" "Handoff" "$1" 2>/dev/null || echo "$1"; }
|
||||
die() { echo "omarchy-handoff: $1" >&2; exit 1; }
|
||||
|
||||
# The daemon owns the socket; every cli call is a no-op without it.
|
||||
require_daemon() {
|
||||
[[ -S $SOCKET ]] || die "lan-mouse daemon is not running (try: omarchy-handoff restart)"
|
||||
}
|
||||
|
||||
# Resolve a client's numeric id from its hostname, since lan-mouse addresses
|
||||
# clients by an index that shifts as entries are added or removed.
|
||||
client_id() {
|
||||
local host="${1:-}"
|
||||
require_daemon
|
||||
if [[ -n $host ]]; then
|
||||
lan-mouse cli list | sed -n "s/^id \([0-9]\+\): ${host}:.*/\1/p" | head -n1
|
||||
else
|
||||
lan-mouse cli list | sed -n 's/^id \([0-9]\+\):.*/\1/p' | head -n1
|
||||
fi
|
||||
}
|
||||
|
||||
first_host() {
|
||||
require_daemon
|
||||
lan-mouse cli list | sed -n 's/^id [0-9]\+: \([^:]*\):.*/\1/p' | head -n1
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
if [[ -S $SOCKET ]]; then
|
||||
echo "daemon: running"
|
||||
else
|
||||
echo "daemon: stopped"
|
||||
return 0
|
||||
fi
|
||||
echo "peers:"
|
||||
lan-mouse cli list | sed 's/^/ /'
|
||||
echo "fingerprint (this machine):"
|
||||
echo " $(cmd_fingerprint)"
|
||||
}
|
||||
|
||||
cmd_fingerprint() {
|
||||
[[ -f $CERT ]] || die "no certificate yet at $CERT (start the daemon once)"
|
||||
# MUST be lowercase. lan-mouse compares with a plain HashMap key lookup
|
||||
# (src/listen.rs: authorized.contains_key(&fingerprints[0])) against
|
||||
# crypto::generate_fingerprint(), which formats "{x:02x}" and .to_lowercase().
|
||||
# openssl prints UPPERCASE, so passing it through unchanged silently never
|
||||
# matches -- that was the real cause of the dead crossing on 2026-10-10.
|
||||
openssl x509 -in "$CERT" -noout -fingerprint -sha256 | cut -d= -f2 | tr "A-Z" "a-z"
|
||||
}
|
||||
|
||||
# Activation is what actually gates input crossing the edge, so on/off/toggle
|
||||
# map to activate/deactivate rather than stopping the daemon.
|
||||
is_active() {
|
||||
local id="$1"
|
||||
lan-mouse cli list | grep -q "^id ${id}:.*active: true"
|
||||
}
|
||||
|
||||
# Arming the edge with no authorized peer key is the one way this locks up a
|
||||
# machine: input gets captured, the peer rejects it unauthenticated, and the
|
||||
# keyboard looks dead. Refuse unless pairing is mutual, or --force is given.
|
||||
# Parse the actual TOML table rather than slicing text: a line-range slice from
|
||||
# the header to EOF also counts keys belonging to any table emitted AFTER
|
||||
# [authorized_fingerprints], and `lan-mouse cli save-config` -- not us -- chooses
|
||||
# that order. An empty fingerprint table followed by any populated table made the
|
||||
# text version return true and arm an unpaired machine, i.e. it failed OPEN into
|
||||
# exactly the lockup this guard exists to prevent.
|
||||
# A malformed or unreadable config counts as 0, so it fails CLOSED.
|
||||
paired() {
|
||||
[[ -f $CONFIG ]] || return 1
|
||||
local n
|
||||
n=$(python3 -I -c '
|
||||
import sys, tomllib
|
||||
try:
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
print(len(tomllib.load(f).get("authorized_fingerprints", {})))
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
print(0)
|
||||
' "$CONFIG")
|
||||
(( n > 0 ))
|
||||
}
|
||||
|
||||
cmd_on() {
|
||||
local force=0
|
||||
[[ ${1:-} == --force ]] && { force=1; shift; }
|
||||
local id; id=$(client_id "${1:-}")
|
||||
[[ -n $id ]] || die "no peer configured"
|
||||
if (( ! force )) && ! paired; then
|
||||
die "no peer fingerprint authorized -- refusing to capture input.
|
||||
Crossing the edge now would forward this keyboard and mouse to a peer that
|
||||
cannot accept it, and the machine would look frozen (release: Ctrl+Shift+Super+Alt).
|
||||
Pair first: omarchy-handoff pair <name> <sha256>
|
||||
Override: omarchy-handoff on --force"
|
||||
fi
|
||||
lan-mouse cli activate "$id"
|
||||
notify "Handoff enabled"
|
||||
}
|
||||
cmd_off() { local id; id=$(client_id "${1:-}"); [[ -n $id ]] || die "no peer configured"; lan-mouse cli deactivate "$id"; notify "Handoff disabled"; }
|
||||
|
||||
cmd_toggle() {
|
||||
local id; id=$(client_id "${1:-}")
|
||||
[[ -n $id ]] || die "no peer configured (try: omarchy-handoff peer <host> <ip>)"
|
||||
if is_active "$id"; then cmd_off "${1:-}"; else cmd_on "${1:-}"; fi
|
||||
}
|
||||
|
||||
# The whole point of the wrapper: the physical side the other laptop sits on is
|
||||
# one value, not a code change.
|
||||
cmd_position() {
|
||||
local edge="${1:-}" id
|
||||
case "$edge" in
|
||||
left|right|top|bottom) ;;
|
||||
*) die "position must be one of: left right top bottom" ;;
|
||||
esac
|
||||
id=$(client_id "${2:-}")
|
||||
[[ -n $id ]] || die "no peer configured"
|
||||
lan-mouse cli set-position "$id" "$edge"
|
||||
lan-mouse cli save-config
|
||||
notify "Peer is now on the $edge"
|
||||
}
|
||||
|
||||
cmd_peer() {
|
||||
local host="${1:-}" ip="${2:-}" edge="${3:-left}"
|
||||
[[ -n $host && -n $ip ]] || die "usage: omarchy-handoff peer <hostname> <ip>[,<ip>...] [edge]"
|
||||
require_daemon
|
||||
local existing; existing=$(client_id "$host")
|
||||
[[ -n $existing ]] && lan-mouse cli remove-client "$existing"
|
||||
lan-mouse cli add-client --hostname "$host" --ips "$ip" --port 4242
|
||||
cmd_position "$edge" "$host"
|
||||
}
|
||||
|
||||
cmd_pair() {
|
||||
local desc="${1:-}" fp="${2:-}"
|
||||
# Normalise case: a fingerprint copied from openssl or a chat message is
|
||||
# usually uppercase, and lan-mouse's comparison is case-SENSITIVE.
|
||||
fp=$(tr "A-Z" "a-z" <<<"$fp")
|
||||
[[ -n $desc && -n $fp ]] || die "usage: omarchy-handoff pair <description> <sha256-fingerprint>"
|
||||
require_daemon
|
||||
lan-mouse cli authorize-key "$desc" "$fp"
|
||||
lan-mouse cli save-config
|
||||
notify "Authorized $desc"
|
||||
}
|
||||
|
||||
cmd_fixcase() {
|
||||
[[ -f $CONFIG ]] || die "no config at $CONFIG"
|
||||
python3 -I -c "
|
||||
import re, sys
|
||||
path = sys.argv[1]
|
||||
with open(path) as f:
|
||||
s = f.read()
|
||||
pat = re.compile(\"\\\"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){31})\\\"\\\\s*=\")
|
||||
n = sum(1 for m in pat.finditer(s) if any(c.isupper() for c in m.group(1)))
|
||||
s2 = pat.sub(lambda m: chr(34) + m.group(1).lower() + chr(34) + \" =\", s)
|
||||
if n:
|
||||
with open(path, \"w\") as f:
|
||||
f.write(s2)
|
||||
print(n)
|
||||
" "$CONFIG" | {
|
||||
read -r n
|
||||
if (( n > 0 )); then
|
||||
echo "lowercased $n fingerprint(s); restart to load: omarchy-handoff restart"
|
||||
else
|
||||
echo "nothing to fix -- all fingerprints already lowercase"
|
||||
fi
|
||||
}
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
pkill -x lan-mouse 2>/dev/null || true
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
# Keep the log. Discarding it cost real debugging time once: the portal
|
||||
# backend's silent no-op is invisible without these lines.
|
||||
if command -v uwsm-app >/dev/null; then
|
||||
uwsm-app -- lan-mouse daemon >>"$LOG" 2>&1 &
|
||||
else
|
||||
setsid lan-mouse daemon >>"$LOG" 2>&1 &
|
||||
fi
|
||||
sleep 2
|
||||
[[ -S $SOCKET ]] || die "daemon failed to start (see $LOG)"
|
||||
notify "Handoff daemon restarted"
|
||||
}
|
||||
|
||||
# Show what the capture layer is actually doing -- the only way to tell a real
|
||||
# barrier from a backend that selected cleanly and armed nothing.
|
||||
cmd_log() {
|
||||
# The daemon runs as the lan-mouse.service user unit, so its output is in the
|
||||
# journal. It USED to be started by o.launch_on_start() in autostart.lua,
|
||||
# which sent stdout AND stderr to /dev/null -- so for hours this printed a
|
||||
# stale pre-reboot file while the live daemon logged nowhere at all. Read the
|
||||
# journal first; fall back to $LOG only for a hand-started daemon.
|
||||
if systemctl --user --quiet is-enabled lan-mouse.service 2>/dev/null \
|
||||
|| systemctl --user --quiet is-active lan-mouse.service 2>/dev/null; then
|
||||
if [[ ${1:-} == -f ]]; then
|
||||
journalctl --user -u lan-mouse.service -o cat -f
|
||||
else
|
||||
journalctl --user -u lan-mouse.service -o cat --no-pager -n "${1:-40}"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
[[ -f $LOG ]] || die "no journal unit and no log at $LOG (try: omarchy-handoff restart)"
|
||||
echo "# NOTE: reading fallback file $LOG (lan-mouse.service is not active)" >&2
|
||||
if [[ ${1:-} == -f ]]; then tail -f "$LOG"; else tail -n "${1:-40}" "$LOG"; fi
|
||||
}
|
||||
|
||||
# Health check. Deliberately exits non-zero on failure: a check that can only
|
||||
# report success is decoration. Scope is capture + trust-entry SHAPE -- it
|
||||
# cannot prove the peer trusts our key, only that our own table isn't malformed.
|
||||
# Pass a saved log as $1 to parse it instead of probing (used to test failures).
|
||||
cmd_doctor() {
|
||||
local src="${1:-}" rc=0 probe cb last
|
||||
|
||||
echo "== configured backend =="
|
||||
cb=$(python3 -I -c '
|
||||
import sys, tomllib
|
||||
try:
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
print(tomllib.load(f).get("capture_backend") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' "$CONFIG")
|
||||
if [[ -z $cb ]]; then
|
||||
echo " (unset -> defaults to input-capture-portal)"
|
||||
echo " WARNING: that default is a SILENT NO-OP on Hyprland 0.56.2 -- it"
|
||||
echo " initializes cleanly and creates no barriers. Set capture_backend = \"layer-shell\"."
|
||||
rc=1
|
||||
elif [[ $cb != layer-shell ]]; then
|
||||
echo " $cb"
|
||||
echo " WARNING: only layer-shell is known to create real barriers here."
|
||||
rc=1
|
||||
else
|
||||
echo " layer-shell (correct)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== trust entry shape =="
|
||||
# Checks CASE as well as ordering. An uppercase key is a valid TOML table
|
||||
# entry and the right digest, so nothing anywhere complains -- the daemon
|
||||
# just never matches it and both ends send DTLS bad_certificate.
|
||||
local badcase
|
||||
badcase=$(python3 -I -c "
|
||||
import sys, tomllib
|
||||
try:
|
||||
with open(sys.argv[1], \"rb\") as f:
|
||||
fps = tomllib.load(f).get(\"authorized_fingerprints\", {})
|
||||
except Exception:
|
||||
fps = {}
|
||||
print(sum(1 for k in fps if any(c.isupper() for c in k)))
|
||||
" "$CONFIG" 2>/dev/null || echo 0)
|
||||
if [[ ${badcase:-0} -gt 0 ]]; then
|
||||
echo " FAILING -- ${badcase} fingerprint(s) contain UPPERCASE hex."
|
||||
echo " lan-mouse compares the string verbatim and generates lowercase, so"
|
||||
echo " an uppercase key never matches: the digest is right, the case is not,"
|
||||
echo " nothing logs a complaint, and both ends send DTLS bad_certificate."
|
||||
echo " Fix: omarchy-handoff fixcase"
|
||||
rc=1
|
||||
fi
|
||||
# Catches a REVERSED entry, which paired() cannot: a backwards
|
||||
# "description" = "<sha256>" line is a valid non-empty table, and auth then
|
||||
# fails silently and looks exactly like a dropped packet.
|
||||
python3 -I -c '
|
||||
import sys, tomllib
|
||||
try:
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
fps = tomllib.load(f).get("authorized_fingerprints", {})
|
||||
except Exception:
|
||||
print(" could not parse config"); sys.exit(1)
|
||||
if not fps:
|
||||
print(" NO fingerprints authorized -- the peer cannot be trusted yet."); sys.exit(1)
|
||||
bad = [k for k in fps if k.count(":") < 16]
|
||||
for k, v in fps.items():
|
||||
label = "order OK " if k.count(":") >= 16 else "BACKWARDS"
|
||||
print(f" {label} {k[:24]}... = {v!r}")
|
||||
if bad:
|
||||
print(" ERROR: key must be the FINGERPRINT, value the description.")
|
||||
print(" Fix: lan-mouse cli remove-authorized-key <desc>, then")
|
||||
print(" lan-mouse cli authorize-key <desc> <sha256>")
|
||||
sys.exit(1)
|
||||
' "$CONFIG" || rc=1
|
||||
|
||||
echo
|
||||
echo "== peer reachability =="
|
||||
# Added after both machines spent hours green on doctor while a firewall ate
|
||||
# every inbound packet. Capture and trust can both be perfect and nothing
|
||||
# crosses. This reads the DAEMON'S OWN connect outcomes -- ground truth from
|
||||
# the real protocol, not a synthetic probe that proves nothing for UDP.
|
||||
local pip pport ufwline verdict
|
||||
pip=$(python3 -I -c "
|
||||
import sys, tomllib
|
||||
try:
|
||||
with open(sys.argv[1], \"rb\") as f:
|
||||
cs = tomllib.load(f).get(\"clients\", [])
|
||||
ips = cs[0].get(\"ips\", []) if cs else []
|
||||
print(ips[0] if ips else \"\")
|
||||
except Exception:
|
||||
print(\"\")
|
||||
" "$CONFIG" 2>/dev/null || true)
|
||||
pport=$(python3 -I -c "
|
||||
import sys, tomllib
|
||||
try:
|
||||
with open(sys.argv[1], \"rb\") as f:
|
||||
d = tomllib.load(f)
|
||||
print(d.get(\"clients\", [{}])[0].get(\"port\") or d.get(\"port\") or 4242)
|
||||
except Exception:
|
||||
print(4242)
|
||||
" "$CONFIG" 2>/dev/null || true)
|
||||
|
||||
if [[ -z $pip ]]; then
|
||||
echo " no peer ip configured -- nothing to reach."
|
||||
rc=1
|
||||
else
|
||||
echo " peer: ${pip}:${pport}"
|
||||
if ping -c1 -W2 -q "$pip" >/dev/null 2>&1; then
|
||||
echo " ICMP: host is up"
|
||||
else
|
||||
echo " ICMP: no reply -- peer is off, asleep, or on another network."
|
||||
echo " (Everything below will fail for that reason alone.)"
|
||||
rc=1
|
||||
fi
|
||||
|
||||
# Our own inbound half. This is the check that was missing entirely.
|
||||
if command -v ufw >/dev/null && sudo -n ufw status >/dev/null 2>&1; then
|
||||
ufwline=$(sudo -n ufw status 2>/dev/null | grep -E "^${pport}/udp" || true)
|
||||
if [[ -n $ufwline ]]; then
|
||||
echo " our ufw: ALLOWS ${pport}/udp -- ${ufwline}"
|
||||
else
|
||||
echo " our ufw: NO rule for ${pport}/udp. If the default is deny incoming,"
|
||||
echo " the peer's handshake never reaches our daemon and it looks like a"
|
||||
echo " silent drop. Check: sudo ufw status verbose"
|
||||
rc=1
|
||||
fi
|
||||
else
|
||||
echo " our ufw: not checked (needs passwordless sudo); verify by hand."
|
||||
fi
|
||||
|
||||
# What did the real protocol actually do most recently?
|
||||
verdict=$(cmd_log 400 2>/dev/null | grep -aE "connect|Alert|connection" | tail -n1 || true)
|
||||
if [[ -z $verdict ]]; then
|
||||
echo " daemon log: no connection attempt recorded yet."
|
||||
echo " (Cross the edge once, then re-run -- the attempt is what tells us"
|
||||
echo " whether this is a firewall, a trust, or a routing problem.)"
|
||||
else
|
||||
echo " last connect line: ${verdict#*] }"
|
||||
case $verdict in
|
||||
*"Alert is Fatal or Close Notify"*)
|
||||
echo " => the PEER ACTIVELY REJECTED our certificate (DTLS fatal alert)."
|
||||
echo " Not a firewall: something answered. The peer does not trust"
|
||||
echo " our fingerprint. Ours is:"
|
||||
echo " $(cmd_fingerprint 2>/dev/null || echo "<no cert>")"
|
||||
echo " The peer must authorize exactly that, keyed BY FINGERPRINT."
|
||||
rc=1 ;;
|
||||
*"Connection timed out"*)
|
||||
echo " => NOTHING ANSWERED. Either the peer's firewall is dropping"
|
||||
echo " ${pport}/udp, or our reply left via a different source address"
|
||||
echo " than the peer sent to (two NICs on one subnet does this --"
|
||||
echo " check: ip route get ${pip})."
|
||||
rc=1 ;;
|
||||
*"connection established"*|*"connected to"*)
|
||||
echo " => transport is UP." ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== barrier probe =="
|
||||
if [[ -n $src ]]; then
|
||||
probe=$(cat "$src")
|
||||
else
|
||||
# test-capture runs until killed, so timeout's 124 is expected, not a failure.
|
||||
probe=$(timeout 8 lan-mouse --capture-backend "${cb:-layer-shell}" test-capture 2>&1 || true)
|
||||
fi
|
||||
|
||||
if ! grep -q "adding capture" <<<"$probe"; then
|
||||
echo " NO BARRIERS CREATED -- capture is dead."
|
||||
echo " Full probe output:"
|
||||
sed 's/^/ /' <<<"$probe"
|
||||
rc=1
|
||||
else
|
||||
# test-capture probes ALL FOUR positions and the registration order is NOT
|
||||
# stable, so a bare `| tail -n1` returns whichever edge happened to land
|
||||
# last -- not ours. Observed this report "left" and "bottom" on consecutive
|
||||
# runs from an unchanged config. Validate only OUR configured edge.
|
||||
# Within that edge, the populated list grows as each output registers, so
|
||||
# the LAST line for it is the authoritative one.
|
||||
local pos
|
||||
pos=$(python3 -I -c '
|
||||
import sys, tomllib
|
||||
try:
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
cs = tomllib.load(f).get("clients", [])
|
||||
print(cs[0].get("position", "") if cs else "")
|
||||
except Exception:
|
||||
print("")
|
||||
' "$CONFIG")
|
||||
if [[ -z $pos ]]; then
|
||||
echo " cannot determine our configured edge from $CONFIG"
|
||||
rc=1
|
||||
else
|
||||
echo " our configured edge: $pos"
|
||||
last=$(grep "adding capture for position ${pos} " <<<"$probe" | tail -n1 || true)
|
||||
if [[ -z $last ]]; then
|
||||
echo " FAILING -- no barrier was ever created for \"$pos\"."
|
||||
echo " (Other edges may have armed; that does not help us.)"
|
||||
rc=1
|
||||
else
|
||||
echo " last line for $pos: ${last#*] }"
|
||||
if [[ $last == *"using outputs: []"* ]]; then
|
||||
echo " FAILING -- the final pass for $pos lists no outputs."
|
||||
echo " (An empty pass BEFORE populated ones is normal and transient.)"
|
||||
rc=1
|
||||
else
|
||||
echo " OK -- barrier is real on the output(s) above, so $pos can fire."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
if (( rc == 0 )); then
|
||||
echo "RESULT: OK"
|
||||
else
|
||||
echo "RESULT: PROBLEMS FOUND (see above)"
|
||||
fi
|
||||
echo " Scope: capture barriers, reachability, and the shape of OUR OWN"
|
||||
echo " trust entry. It cannot prove the peer trusts our key unless the"
|
||||
echo " daemon has actually attempted a connection -- cross the edge once."
|
||||
return $rc
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
status) cmd_status ;;
|
||||
toggle) shift; cmd_toggle "$@" ;;
|
||||
on|enable) shift; cmd_on "$@" ;;
|
||||
off|disable) shift; cmd_off "$@" ;;
|
||||
position) shift; cmd_position "$@" ;;
|
||||
peer) shift; cmd_peer "$@" ;;
|
||||
fingerprint) cmd_fingerprint ;;
|
||||
pair) shift; cmd_pair "$@" ;;
|
||||
restart) cmd_restart ;;
|
||||
log) shift; cmd_log "$@" ;;
|
||||
fixcase) shift; cmd_fixcase "$@" ;;
|
||||
doctor) shift; cmd_doctor "$@" ;;
|
||||
*) die "unknown command '${1}'" ;;
|
||||
esac
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
# Pull the live machine's state back into this repo - the reverse of install.sh.
|
||||
#
|
||||
# Run it after customizing something by hand, then review `git diff` and commit.
|
||||
# It only refreshes paths the repo already tracks; it never invents new ones, so
|
||||
# adding a newly customized file is a deliberate act (see README, "Adding a file").
|
||||
#
|
||||
# ./capture.sh # refresh everything
|
||||
# ./capture.sh config # only the named steps
|
||||
# ./capture.sh --list
|
||||
#
|
||||
# Steps: config themes plugins bin services system packages
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STEPS=(config themes plugins bin services system packages)
|
||||
|
||||
c_ch=$'\033[33m'; c_skip=$'\033[2m'; c_warn=$'\033[33m'; c_hd=$'\033[1m'; c_off=$'\033[0m'
|
||||
[[ -t 1 ]] || { c_ch=; c_skip=; c_warn=; c_hd=; c_off=; }
|
||||
say() { printf '%s\n' "$*"; }
|
||||
head_() { printf '\n%s==> %s%s\n' "$c_hd" "$*" "$c_off"; }
|
||||
chg() { printf ' %sM %s%s\n' "$c_ch" "$*" "$c_off"; }
|
||||
skip() { printf ' %s= %s%s\n' "$c_skip" "$*" "$c_off"; }
|
||||
warn() { printf ' %s! %s%s\n' "$c_warn" "$*" "$c_off"; }
|
||||
gone() { printf ' %s? %s%s\n' "$c_warn" "$*" "$c_off"; }
|
||||
|
||||
# pull_back <live> <repo-copy> <label>
|
||||
pull_back() {
|
||||
local live=$1 dest=$2 label=$3
|
||||
if [[ ! -e $live ]]; then gone "$label - not on this machine any more"; return; fi
|
||||
if cmp -s "$live" "$dest"; then skip "$label"; else cp "$live" "$dest"; chg "$label"; fi
|
||||
}
|
||||
|
||||
step_config() {
|
||||
head_ "~/.config -> config/"
|
||||
local dest rel
|
||||
while IFS= read -r -d '' dest; do
|
||||
rel=${dest#"$REPO/config/"}
|
||||
pull_back "$HOME/.config/$rel" "$dest" "$rel"
|
||||
done < <(find "$REPO/config" -type f -print0 | sort -z)
|
||||
}
|
||||
|
||||
step_themes() {
|
||||
head_ "Themes"
|
||||
local t name live
|
||||
for t in "$REPO"/themes/*/; do
|
||||
[[ -d $t ]] || continue
|
||||
name=$(basename -- "$t")
|
||||
live="$HOME/.config/omarchy/themes/$name"
|
||||
if [[ ! -d $live ]]; then gone "$name - no longer installed"; continue; fi
|
||||
if diff -rq "$live" "$t" >/dev/null 2>&1; then
|
||||
skip "$name"
|
||||
else
|
||||
rm -rf "$t"; mkdir -p "$t"; cp -r "$live/." "$t/"
|
||||
chg "$name"
|
||||
fi
|
||||
done
|
||||
# Flag themes installed by hand that the repo does not carry.
|
||||
local l lname
|
||||
for l in "$HOME"/.config/omarchy/themes/*/; do
|
||||
[[ -d $l ]] || continue
|
||||
lname=$(basename -- "$l")
|
||||
[[ -d "$REPO/themes/$lname" ]] && continue
|
||||
[[ -e "$l/.aether-managed" ]] && continue
|
||||
warn "$lname is installed but not in this repo (cp -r it into themes/ to track it)"
|
||||
done
|
||||
}
|
||||
|
||||
step_plugins() {
|
||||
head_ "Plugin pins and local work"
|
||||
local dir="$HOME/.config/omarchy/plugins"
|
||||
local tmp; tmp=$(mktemp)
|
||||
{
|
||||
echo "# Omarchy shell plugins. Fields: id<TAB>repo<TAB>pinned-sha"
|
||||
echo "# install.sh clones each into ~/.config/omarchy/plugins/<id> and checks out the sha."
|
||||
local p id url sha
|
||||
for p in "$dir"/*/; do
|
||||
[[ -d $p/.git ]] || continue
|
||||
id=$(basename -- "$p")
|
||||
url=$(git -C "$p" remote get-url origin 2>/dev/null) || continue
|
||||
sha=$(git -C "$p" rev-parse HEAD)
|
||||
printf '%s\t%s\t%s\n' "$id" "$url" "$sha"
|
||||
done
|
||||
} > "$tmp"
|
||||
if cmp -s "$tmp" "$REPO/plugins.txt"; then skip "plugins.txt"; else mv "$tmp" "$REPO/plugins.txt"; chg "plugins.txt"; fi
|
||||
rm -f "$tmp"
|
||||
|
||||
# herdr-hud's local-only branch, re-bundled
|
||||
local p="$dir/indie.herdr-hud"
|
||||
if [[ -d $p/.git ]] && git -C "$p" rev-parse --verify --quiet local >/dev/null; then
|
||||
local b="$REPO/plugin-patches/indie.herdr-hud.bundle"
|
||||
git -C "$p" bundle create "$b.new" origin/main..local >/dev/null 2>&1
|
||||
# Bundles embed a timestamp-free pack but differ byte-wise; compare the tips instead.
|
||||
if [[ -f $b ]] && [[ $(git -C "$p" bundle list-heads "$b" 2>/dev/null) == $(git -C "$p" bundle list-heads "$b.new" 2>/dev/null) ]]; then
|
||||
rm -f "$b.new"; skip "indie.herdr-hud.bundle"
|
||||
else
|
||||
mv "$b.new" "$b"; chg "indie.herdr-hud.bundle"
|
||||
fi
|
||||
[[ -f $p/bin/herdr-agents ]] && pull_back "$p/bin/herdr-agents" \
|
||||
"$REPO/plugin-patches/indie.herdr-hud--bin--herdr-agents" "indie.herdr-hud bin/herdr-agents"
|
||||
fi
|
||||
|
||||
local f id
|
||||
for f in "$REPO"/plugin-patches/*-worktree.patch; do
|
||||
[[ -f $f ]] || continue
|
||||
id=$(basename -- "$f"); id=${id%-worktree.patch}
|
||||
if [[ ! -d $dir/$id/.git ]]; then gone "$id - not installed"; continue; fi
|
||||
local tmp2; tmp2=$(mktemp)
|
||||
git -C "$dir/$id" diff > "$tmp2"
|
||||
if cmp -s "$tmp2" "$f"; then skip "$id-worktree.patch"; else mv "$tmp2" "$f"; chg "$id-worktree.patch"; fi
|
||||
rm -f "$tmp2"
|
||||
done
|
||||
}
|
||||
|
||||
step_bin() {
|
||||
head_ "~/.local/bin -> bin/"
|
||||
local f
|
||||
for f in "$REPO"/bin/*; do
|
||||
[[ -f $f ]] || continue
|
||||
pull_back "$HOME/.local/bin/$(basename -- "$f")" "$f" "$(basename -- "$f")"
|
||||
done
|
||||
}
|
||||
|
||||
step_services() {
|
||||
head_ "systemd user units"
|
||||
local f name
|
||||
for f in "$REPO"/systemd/user/*.service; do
|
||||
[[ -f $f ]] || continue
|
||||
name=$(basename -- "$f")
|
||||
pull_back "$HOME/.config/systemd/user/$name" "$f" "$name"
|
||||
done
|
||||
}
|
||||
|
||||
step_system() {
|
||||
head_ "/etc drop-ins"
|
||||
local dest rel
|
||||
while IFS= read -r -d '' dest; do
|
||||
rel=${dest#"$REPO/system/"}
|
||||
pull_back "/etc/$rel" "$dest" "/etc/$rel"
|
||||
done < <(find "$REPO/system" -type f -print0 | sort -z)
|
||||
}
|
||||
|
||||
# Report drift in the package delta without rewriting the hand-annotated file:
|
||||
# packages.txt carries a "why" comment per line that a regenerated list would lose.
|
||||
step_packages() {
|
||||
head_ "Package delta (report only)"
|
||||
local listed; listed=$(mktemp)
|
||||
sed 's/#.*//' "$REPO/packages.txt" | tr -d '[:blank:]' | grep -v '^$' | grep -v '^\[' | sort -u > "$listed"
|
||||
|
||||
local baseline; baseline=$(mktemp)
|
||||
{
|
||||
pacman -Qi omarchy 2>/dev/null | awk '/^Depends On/{sub(/^Depends On *: /,"");print}' \
|
||||
| tr ' ' '\n' | sed 's/[<>=].*//'
|
||||
cat /usr/share/omarchy/install/omarchy-base.packages 2>/dev/null
|
||||
} | grep -v '^$' | sort -u > "$baseline"
|
||||
|
||||
local extra; extra=$(mktemp)
|
||||
pacman -Qqe 2>/dev/null | sort -u | comm -23 - "$baseline" > "$extra"
|
||||
|
||||
local untracked; untracked=$(comm -23 "$extra" "$listed" | grep -vxE 'base|base-devel|sudo|omarchy|mkinitcpio|linux-firmware|efibootmgr|devtools|cmake|ninja|wayvnc|sunshine|freerdp|remmina|omarchy-remote-desktop-backend|pipewire-debug' | tr '\n' ' ')
|
||||
local removed; removed=$(comm -13 "$extra" "$listed" | tr '\n' ' ')
|
||||
|
||||
[[ -n ${untracked// } ]] && warn "installed but not in packages.txt: $untracked" || skip "nothing installed that packages.txt misses"
|
||||
[[ -n ${removed// } ]] && warn "in packages.txt but not installed: $removed"
|
||||
say " packages.txt keeps a 'why' comment per package, so edit it by hand."
|
||||
rm -f "$listed" "$baseline" "$extra"
|
||||
}
|
||||
|
||||
main() {
|
||||
local want=()
|
||||
for a in "$@"; do
|
||||
case $a in
|
||||
--list|-l) printf '%s\n' "${STEPS[@]}"; exit 0 ;;
|
||||
-h|--help) sed -n '2,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-*) warn "unknown option: $a"; exit 2 ;;
|
||||
*) if [[ " ${STEPS[*]} " == *" $a "* ]]; then want+=("$a"); else warn "unknown step: $a"; exit 2; fi ;;
|
||||
esac
|
||||
done
|
||||
(( ${#want[@]} )) || want=("${STEPS[@]}")
|
||||
|
||||
say "${c_hd}capture${c_off} repo=$REPO host=$(hostname)"
|
||||
for s in "${want[@]}"; do "step_$s"; done
|
||||
|
||||
head_ "Done"
|
||||
say " Review with: git -C $REPO diff"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,28 @@
|
||||
general.import = [ "~/.local/state/omarchy/current/theme/alacritty.toml" ]
|
||||
|
||||
[env]
|
||||
TERM = "xterm-256color"
|
||||
|
||||
[terminal]
|
||||
osc52 = "CopyPaste"
|
||||
|
||||
[font]
|
||||
normal = { family = "JetBrainsMono Nerd Font", style = "Regular" }
|
||||
bold = { family = "JetBrainsMono Nerd Font", style = "Bold" }
|
||||
italic = { family = "JetBrainsMono Nerd Font", style = "Italic" }
|
||||
size = 8
|
||||
|
||||
[window]
|
||||
padding.x = 14
|
||||
padding.y = 14
|
||||
decorations = "None"
|
||||
|
||||
[keyboard]
|
||||
bindings = [
|
||||
{ key = "Insert", mods = "Shift", action = "Paste" },
|
||||
{ key = "Insert", mods = "Control", action = "Copy" },
|
||||
# Send Shift+Return as CSI-u so TUIs can distinguish it from Return without treating it as Alt+Return.
|
||||
{ key = "Return", mods = "Shift", chars = "\u001B[13;2u" },
|
||||
# Legacy encoding sends Alt+Shift+Return the same as Alt+Return; send CSI-u so tmux can match M-S-Enter.
|
||||
{ key = "Return", mods = "Alt|Shift", chars = "\u001B[13;4u" }
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[main]
|
||||
include=~/.local/state/omarchy/current/theme/foot.ini
|
||||
term=xterm-256color
|
||||
font=JetBrainsMono Nerd Font:size=8
|
||||
pad=14x14
|
||||
initial-window-mode=windowed
|
||||
workers=0
|
||||
|
||||
[scrollback]
|
||||
lines=10000
|
||||
multiplier=7.0
|
||||
|
||||
[cursor]
|
||||
style=block
|
||||
blink=no
|
||||
|
||||
[key-bindings]
|
||||
clipboard-copy=Control+Insert Control+Shift+c XF86Copy
|
||||
primary-paste=none
|
||||
clipboard-paste=Shift+Insert Control+Shift+v XF86Paste
|
||||
|
||||
[text-bindings]
|
||||
# Send Shift+Return as CSI-u so TUIs can distinguish it from Return.
|
||||
\x1b[13;2u=Shift+Return
|
||||
# Send Alt+Shift+Return as CSI-u so tmux can match M-S-Enter.
|
||||
\x1b[13;4u=Mod1+Shift+Return
|
||||
@@ -0,0 +1,41 @@
|
||||
# Dynamic theme colors
|
||||
config-file = ?"~/.local/state/omarchy/current/theme/ghostty.conf"
|
||||
|
||||
# Font
|
||||
font-family = "JetBrainsMono Nerd Font"
|
||||
font-style = Regular
|
||||
font-size = 12
|
||||
|
||||
# Window
|
||||
window-theme = ghostty
|
||||
window-padding-x = 14
|
||||
window-padding-y = 14
|
||||
confirm-close-surface=false
|
||||
resize-overlay = never
|
||||
gtk-toolbar-style = flat
|
||||
|
||||
# Cursor styling
|
||||
cursor-style = "block"
|
||||
cursor-style-blink = false
|
||||
|
||||
# Cursor styling + SSH session terminfo
|
||||
# (all shell integration options must be passed together)
|
||||
shell-integration-features = no-cursor,ssh-env
|
||||
|
||||
# Keyboard bindings
|
||||
keybind = shift+insert=paste_from_clipboard
|
||||
keybind = control+insert=copy_to_clipboard
|
||||
# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter.
|
||||
keybind = shift+enter=csi:13;2u
|
||||
# Legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter.
|
||||
keybind = alt+shift+enter=csi:13;4u
|
||||
keybind = super+control+shift+alt+arrow_down=resize_split:down,100
|
||||
keybind = super+control+shift+alt+arrow_up=resize_split:up,100
|
||||
keybind = super+control+shift+alt+arrow_left=resize_split:left,100
|
||||
keybind = super+control+shift+alt+arrow_right=resize_split:right,100
|
||||
|
||||
# Slowdown mouse scrolling
|
||||
mouse-scroll-multiplier = 0.95
|
||||
|
||||
# Fix general slowness on hyprland (https://github.com/ghostty-org/ghostty/discussions/3224)
|
||||
async-backend = epoll
|
||||
@@ -0,0 +1,107 @@
|
||||
onboarding = false
|
||||
# Mirrors the Omarchy tmux config in config/tmux/tmux.conf
|
||||
# tmux session -> herdr workspace, tmux window -> herdr tab, tmux pane -> herdr pane
|
||||
|
||||
[theme]
|
||||
# tmux ran on the terminal's own palette (bg=default, fg=default, ANSI blue accents)
|
||||
name = "terminal"
|
||||
|
||||
[theme.custom]
|
||||
# The active tab is drawn as panel_bg text on an accent background, so panel_bg
|
||||
# has to be dark for it to read - same colors as status-left's "#[fg=black,bg=blue]"
|
||||
panel_bg = "black"
|
||||
|
||||
[terminal]
|
||||
# Matches -c "#{pane_current_path}" on every split, window, and session
|
||||
new_cwd = "follow"
|
||||
|
||||
[keys]
|
||||
prefix = "ctrl+space"
|
||||
|
||||
# Config and help
|
||||
reload_config = "prefix+q"
|
||||
help = "prefix+?"
|
||||
detach = "prefix+d"
|
||||
|
||||
# Copy mode
|
||||
copy_mode = "prefix+["
|
||||
|
||||
# Panes
|
||||
split_horizontal = ["prefix+h", "alt+enter"]
|
||||
split_vertical = ["prefix+v", "alt+shift+enter"]
|
||||
close_pane = ["prefix+x", "alt+esc"]
|
||||
zoom = "prefix+z"
|
||||
last_pane = "prefix+;"
|
||||
|
||||
focus_pane_left = "ctrl+alt+left"
|
||||
focus_pane_down = "ctrl+alt+down"
|
||||
focus_pane_up = "ctrl+alt+up"
|
||||
focus_pane_right = "ctrl+alt+right"
|
||||
|
||||
resize_mode = ["prefix+ctrl+left", "prefix+ctrl+down", "prefix+ctrl+up", "prefix+ctrl+right"]
|
||||
|
||||
# Like resize-pane on C-M-S-arrows
|
||||
resize_pane_left = "ctrl+alt+shift+left"
|
||||
resize_pane_down = "ctrl+alt+shift+down"
|
||||
resize_pane_up = "ctrl+alt+shift+up"
|
||||
resize_pane_right = "ctrl+alt+shift+right"
|
||||
|
||||
# No tmux equivalent; herdr's default prefix+shift+p is taken by previous session
|
||||
rename_pane = "prefix+shift+o"
|
||||
|
||||
# Windows -> tabs
|
||||
new_tab = "prefix+c"
|
||||
rename_tab = "prefix+r"
|
||||
close_tab = "prefix+k"
|
||||
switch_tab = ["prefix+1..9", "alt+1..9"]
|
||||
previous_tab = ["prefix+p", "alt+left"]
|
||||
next_tab = ["prefix+n", "alt+right"]
|
||||
|
||||
# Like swap-window -t -1/+1 on M-S-Left/Right
|
||||
move_tab_previous = "alt+shift+left"
|
||||
move_tab_next = "alt+shift+right"
|
||||
|
||||
# Sessions -> workspaces
|
||||
new_workspace = "prefix+shift+c"
|
||||
rename_workspace = "prefix+shift+r"
|
||||
close_workspace = "prefix+shift+k"
|
||||
previous_workspace = ["prefix+shift+p", "alt+up"]
|
||||
next_workspace = ["prefix+shift+n", "alt+down"]
|
||||
|
||||
[ui]
|
||||
accent = "blue"
|
||||
|
||||
# tmux drew single-line dividers between adjacent panes and no outer frame
|
||||
pane_gaps = false
|
||||
pane_outer_borders = false
|
||||
|
||||
# tmux had no scrollbar column beside its panes
|
||||
pane_scrollbars = false
|
||||
|
||||
# kill-window and kill-session never asked
|
||||
confirm_close = false
|
||||
|
||||
# automatic-rename gave windows a name without prompting
|
||||
prompt_new_tab_name = false
|
||||
|
||||
# set -g mouse on
|
||||
mouse_capture = true
|
||||
|
||||
# status-right had the zoom flag followed by #h
|
||||
tab_bar_right = [{ type = "zoom" }, { type = "hostname" }]
|
||||
|
||||
# set -g set-titles on / set -g set-titles-string '#h:#W', where tmux's #W was
|
||||
# the basename of the pane cwd. This is what Hyprland shows in the group bar,
|
||||
# and it resolves on the server so remote sessions name the remote host.
|
||||
window_title = "{hostname}: {workspace}"
|
||||
agent_panel_sort = "spaces"
|
||||
|
||||
# Claude agents show their session name in bold: the /rename name, or Claude's
|
||||
# generated topic before a rename. herdr-web reports it as the $claude_name pane
|
||||
# token (~/Work/omarchy-playground/herdr-web/src/agents.js); workspace and tab
|
||||
# move to the second row.
|
||||
[ui.sidebar.agents.rows_by_agent]
|
||||
claude = [
|
||||
["state_icon", { token = "$claude_name", bold = true }],
|
||||
[{ token = "workspace", bold = false, dim = true }, { token = "tab", dim = true }],
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Extra autostart processes.
|
||||
-- o.launch_on_start("my-service")
|
||||
|
||||
-- Keyboard/mouse sharing with the other Omarchy laptop (lan-mouse).
|
||||
-- Runs as a daemon; input only crosses the screen edge once the peer is
|
||||
-- activated, so starting it here is inert until Handoff is toggled on.
|
||||
-- Superseded by the lan-mouse.service systemd user unit (journald logging,
|
||||
-- ordering after the portal, Restart=on-failure). launch_on_start sent both
|
||||
-- output streams to /dev/null, which hid every pairing failure.
|
||||
-- o.launch_on_start("lan-mouse daemon")
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Keep only your personal keybinding overrides here. Add new bindings or
|
||||
-- unbind defaults before replacing them.
|
||||
|
||||
-- See current bindings and descriptions:
|
||||
-- omarchy menu keybindings --print
|
||||
|
||||
-- To disable every Omarchy default binding, set this in
|
||||
-- ~/.config/hypr/hyprland.lua before require("default.hypr.omarchy"), then add
|
||||
-- only the bindings you want below:
|
||||
-- omarchy_default_bindings = false
|
||||
|
||||
-- To disable all preinstalled app/webapp bindings, set:
|
||||
-- omarchy_preinstalled_bindings = false
|
||||
|
||||
-- Add a new binding.
|
||||
-- o.bind("SUPER + SHIFT + R", "SSH", "alacritty -e ssh your-server")
|
||||
|
||||
-- Change an existing binding by unbinding it first, then binding the key again.
|
||||
-- This example changes SUPER+SPACE from the launcher to the Omarchy root menu.
|
||||
-- hl.unbind("SUPER + SPACE")
|
||||
-- o.bind("SUPER + SPACE", "Omarchy menu", "omarchy-menu toggle root")
|
||||
|
||||
-- Disable a default binding without replacing it.
|
||||
-- hl.unbind("SUPER + SHIFT + B")
|
||||
|
||||
-- Logitech MX Keys examples:
|
||||
-- o.bind("SUPER + SHIFT + S", nil, "omarchy-capture-screenshot")
|
||||
-- o.bind("SUPER + H", nil, "voxtype record toggle")
|
||||
-- o.bind("SUPER + PERIOD", nil, "omarchy-shell shell toggle omarchy.emojis")
|
||||
|
||||
-- Open Claude instead of the stock ChatGPT webapp.
|
||||
hl.unbind("SUPER + SHIFT + A")
|
||||
o.bind("SUPER + SHIFT + A", "Claude", { webapp = "https://claude.ai" })
|
||||
|
||||
-- Herdr HUD overlay (indie.herdr-hud native plugin, local branch).
|
||||
o.bind("SUPER + H", "Toggle Herdr HUD", "omarchy-shell shell toggle indie.herdr-hud '{}'")
|
||||
o.bind("SUPER + ALT + H", "Show or hide Herdr HUD button", "omarchy-shell shell call indie.herdr-hud toggleVisibility '{}'")
|
||||
o.bind("SUPER + CTRL + DOWN", "Herdr HUD: next agent", "omarchy-shell shell call indie.herdr-hud nextAgent '{}'")
|
||||
o.bind("SUPER + CTRL + UP", "Herdr HUD: previous agent", "omarchy-shell shell call indie.herdr-hud previousAgent '{}'")
|
||||
|
||||
-- Handoff: share this keyboard/mouse with the other Omarchy laptop.
|
||||
-- SUPER+K is already "Keybindings", so this takes SUPER+SHIFT+K.
|
||||
o.bind("SUPER + SHIFT + K", "Toggle Handoff (keyboard/mouse sharing)", "omarchy-handoff toggle")
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Learn how to configure Hyprland: https://wiki.hypr.land/Configuring/Start/
|
||||
|
||||
-- Omarchy's bootstrap keeps path setup out of this user config.
|
||||
dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/bootstrap.lua")
|
||||
|
||||
-- Disable all Omarchy default bindings. Add your own in hypr/bindings.lua.
|
||||
-- omarchy_default_bindings = false
|
||||
--
|
||||
-- Or disable only bindings for Omarchy's preinstalled apps/web apps while
|
||||
-- keeping core window-manager bindings:
|
||||
-- omarchy_preinstalled_bindings = false
|
||||
|
||||
-- Load Omarchy defaults.
|
||||
require("default.hypr.omarchy")
|
||||
|
||||
-- Put your personal overrides in these files. They're loaded after Omarchy's
|
||||
-- defaults so package updates can improve the defaults without rewriting your
|
||||
-- ~/.config/hypr files.
|
||||
require("hypr.monitors")
|
||||
require("hypr.input")
|
||||
require("hypr.bindings")
|
||||
require("hypr.looknfeel")
|
||||
require("hypr.autostart")
|
||||
|
||||
-- Toggle config flags dynamically.
|
||||
require("default.hypr.toggles")
|
||||
|
||||
-- Add any other personal Hyprland configuration below.
|
||||
-- o.window("qemu", { workspace = "5" })
|
||||
|
||||
-- Added by hyprmoncfg: its generated monitor rules load last, so nothing before this can override the applied layout.
|
||||
do local path = os.getenv("HOME") .. "/.config/hypr/hyprmoncfg-monitors.lua"; local file = io.open(path, "r"); if file then file:close(); dofile(path) end end
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Keep only your personal input overrides here. Uncommented settings below
|
||||
-- replace Omarchy's defaults.
|
||||
|
||||
-- Keyboard layout and options.
|
||||
-- See https://wiki.hypr.land/Configuring/Basics/Variables/#input
|
||||
-- hl.config({
|
||||
-- input = {
|
||||
-- -- Use multiple keyboard layouts and switch between them with Left Alt + Right Alt.
|
||||
-- kb_layout = "us,dk,eu",
|
||||
-- kb_options = "compose:caps,shift:both_capslock_cancel,grp:alts_toggle",
|
||||
--
|
||||
-- -- Use a specific keyboard variant if needed (e.g. intl for international keyboards).
|
||||
-- kb_variant = "intl",
|
||||
--
|
||||
-- -- Change speed of keyboard repeat.
|
||||
-- repeat_rate = 40,
|
||||
-- repeat_delay = 250,
|
||||
--
|
||||
-- -- Start with numlock on by default.
|
||||
-- numlock_by_default = true,
|
||||
--
|
||||
-- -- Increase sensitivity for mouse/trackpad (default: 0).
|
||||
-- sensitivity = 0.35,
|
||||
--
|
||||
-- -- Turn off mouse acceleration (default: adaptive).
|
||||
-- accel_profile = "flat",
|
||||
--
|
||||
-- touchpad = {
|
||||
-- -- Use natural (inverse) scrolling.
|
||||
-- natural_scroll = true,
|
||||
--
|
||||
-- -- Use two-finger clicks for right-click instead of lower-right corner.
|
||||
-- clickfinger_behavior = true,
|
||||
--
|
||||
-- -- Control the speed of your scrolling.
|
||||
-- scroll_factor = 0.4,
|
||||
--
|
||||
-- -- Enable the touchpad while typing.
|
||||
-- disable_while_typing = false,
|
||||
--
|
||||
-- -- Left-click-and-drag with three fingers.
|
||||
-- drag_3fg = 1,
|
||||
-- },
|
||||
-- },
|
||||
-- })
|
||||
|
||||
-- App-specific touchpad scroll speeds.
|
||||
-- o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 })
|
||||
-- o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 })
|
||||
|
||||
-- Enable touchpad gestures for changing workspaces.
|
||||
-- See https://wiki.hypr.land/Configuring/Advanced-and-Cool/Gestures/
|
||||
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
|
||||
|
||||
-- Enable touchpad gestures for moving focus (helpful on scrolling layout).
|
||||
-- hl.gesture({ fingers = 3, direction = "left", action = function() hl.dispatch(hl.dsp.focus({ direction = "l" })) end })
|
||||
-- hl.gesture({ fingers = 3, direction = "right", action = function() hl.dispatch(hl.dsp.focus({ direction = "r" })) end })
|
||||
|
||||
-- lan-mouse (omarchy-handoff) virtual pointer: kill the SECOND acceleration pass.
|
||||
-- lan-mouse forwards RELATIVE deltas that the SENDING compositor has already
|
||||
-- accelerated. Omarchy leaves accel_profile unset (adaptive), so the receiver
|
||||
-- amplifies them again and the pointer arrives hypersensitive while the local
|
||||
-- trackpad feels normal. "flat" applies no curve, so a forwarded delta moves
|
||||
-- exactly as far as the sender intended.
|
||||
-- The name is "unknown-device" because wlr-virtual-pointer gives Hyprland no
|
||||
-- name (virtual KEYBOARDS get named, pointers don't). Verified on this machine
|
||||
-- with `lan-mouse test-emulation` + `hyprctl devices`: no real device here is
|
||||
-- named unknown-device* (real pointers are elan0138:*, mouse-passthrough*,
|
||||
-- dp-3-1), so this rule cannot touch the trackpad. Two entries because the
|
||||
-- daemon may hold more than one virtual pointer at a time.
|
||||
hl.device({ name = "unknown-device", sensitivity = 0, accel_profile = "flat" })
|
||||
hl.device({ name = "unknown-device-1", sensitivity = 0, accel_profile = "flat" })
|
||||
@@ -0,0 +1,48 @@
|
||||
-- BEGIN hyprmoncfg wake settings
|
||||
-- Shared with Omarchy while hyprmoncfg manages displays.
|
||||
hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 })
|
||||
-- END hyprmoncfg wake settings
|
||||
-- See https://wiki.hypr.land/Configuring/Basics/Monitors/
|
||||
-- List current monitors and supported resolutions with: hyprctl monitors all
|
||||
--
|
||||
-- SINGLE SOURCE OF TRUTH for display config.
|
||||
-- Do not add monitor rules anywhere else (hyprland.lua, a hyprmoncfg-generated
|
||||
-- file, etc.) -- overlapping rules from several files were what made external
|
||||
-- displays unrecoverable before.
|
||||
|
||||
local omarchy_gdk_scale = 1
|
||||
local omarchy_monitor_scale = 1.25
|
||||
|
||||
hl.env("GDK_SCALE", tostring(omarchy_gdk_scale))
|
||||
|
||||
-- Catch-all: anything not named below gets its EDID-preferred mode.
|
||||
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale })
|
||||
|
||||
-- Built-in laptop panel.
|
||||
hl.monitor({
|
||||
output = "desc:Chimei Innolux Corporation 0x1642",
|
||||
mode = "1920x1200@60",
|
||||
position = "auto",
|
||||
scale = 1.25,
|
||||
})
|
||||
|
||||
-- LG IPS236 on the laptop's own HDMI port.
|
||||
hl.monitor({
|
||||
output = "desc:LG Electronics IPS236 105NDLS5A720",
|
||||
mode = "1920x1080@60",
|
||||
position = "auto",
|
||||
scale = 1,
|
||||
})
|
||||
|
||||
-- LG TV via the Anker USB-C dock.
|
||||
-- Deliberately NOT forced to 3840x2160@60: the dock negotiates DP alt-mode
|
||||
-- with only 2 lanes (pin assignment D), which leaves no bandwidth headroom at
|
||||
-- 4K60. Forcing it produced a "monitor has no mode" loop. "preferred" is
|
||||
-- 3840x2160@30 here, which links reliably. Use 1920x1080@120 instead if you
|
||||
-- want high refresh over this dock.
|
||||
hl.monitor({
|
||||
output = "desc:LG Electronics LG TV SSCR2 0x01010101",
|
||||
mode = "preferred",
|
||||
position = "auto",
|
||||
scale = 1.25,
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
# Remove the include below to disconnect Kitty from Omarchy's theming system.
|
||||
include ~/.local/state/omarchy/current/theme/kitty.conf
|
||||
|
||||
# Settings below override Omarchy's defaults in /etc/xdg/kitty/kitty.conf.
|
||||
# Learn more: https://sw.kovidgoyal.net/kitty/conf/
|
||||
|
||||
# Font
|
||||
# font_family JetBrainsMono Nerd Font
|
||||
# font_size 12
|
||||
|
||||
# Window padding
|
||||
# window_padding_width 14
|
||||
|
||||
# Unmap a shortcut, passing it through to the terminal application
|
||||
# map ctrl+insert
|
||||
|
||||
# Set or replace a shortcut
|
||||
# map ctrl+shift+c copy_to_clipboard
|
||||
|
||||
# Remove all inherited shortcuts, including Kitty's built-in shortcuts
|
||||
# clear_all_shortcuts yes
|
||||
|
||||
font_size 8.0
|
||||
@@ -0,0 +1,26 @@
|
||||
██████████████████████████████████████████████████████
|
||||
██████████████████████████████████████████████████████
|
||||
████ ████ ████
|
||||
████ ████ ████
|
||||
████ █████████████████████ ████████ ████
|
||||
████ █████████████████████ ████████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████████████ ████ ████
|
||||
████████████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ██████████████████████████████████████ ████
|
||||
████ ██████████████████████████████████████ ████
|
||||
████ ████ ████
|
||||
████ ████ ████
|
||||
█████████████████████████████ ████████████████████
|
||||
█████████████████████████████ ████████████████████
|
||||
@@ -0,0 +1,10 @@
|
||||
▄▄▄
|
||||
▄█████▄ ▄███████████▄ ▄███████ ▄███████ ▄███████ ▄█ █▄ ▄█ █▄
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▀ ███ ███ ███ ███
|
||||
███ ███ ███ ███ ███ ▄███▄▄▄███ ▄███▄▄▄██▀ ███ ▄███▄▄▄███▄ ███▄▄▄███
|
||||
███ ███ ███ ███ ███ ▀███▀▀▀███ ▀███▀▀▀▀ ███ ▀▀███▀▀▀███ ▀▀▀▀▀▀███
|
||||
███ ███ ███ ███ ███ ███ ███ ██████████ ███ █▄ ███ ███ ▄██ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
|
||||
▀█████▀ ▀█ ███ █▀ ███ █▀ ███ ███ ███████▀ ███ █▀ ▀█████▀
|
||||
███ █▀
|
||||
@@ -0,0 +1 @@
|
||||
claude
|
||||
@@ -0,0 +1,12 @@
|
||||
# btop Activity defaults. Use "More plugin settings > Open settings file"
|
||||
# to edit your own copy. Valid saves apply immediately; no restart is needed.
|
||||
# Keep each setting on one line. Omitted settings use the defaults below.
|
||||
|
||||
# Interval ladder, in milliseconds, that the Update interval arrows step
|
||||
# through. Any whole number from 100 ms to one day can still be typed.
|
||||
poll_intervals = [250, 500, 1000, 2000, 5000]
|
||||
|
||||
# What a left click on the bar icon does. "open" launches or focuses btop;
|
||||
# "toggle" also closes the plugin's btop window. The popup's start action always
|
||||
# opens or focuses btop, regardless of this setting.
|
||||
left_click = "toggle"
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"version": 1,
|
||||
"idle": {
|
||||
"screensaver": 1200,
|
||||
"lock": 1800
|
||||
},
|
||||
"bar": {
|
||||
"position": "top",
|
||||
"transparent": false,
|
||||
"centerAnchor": "omarchy.clock",
|
||||
"layout": {
|
||||
"left": [
|
||||
{
|
||||
"id": "omarchy.menu"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.workspaces"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.active-window"
|
||||
}
|
||||
],
|
||||
"center": [
|
||||
{
|
||||
"id": "omarchy.indicators"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.clock",
|
||||
"format": "dddd HH:mm",
|
||||
"formatAlt": "d MMMM 'W'ww yyyy",
|
||||
"verticalFormat": "HH\n—\nmm"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.keyboard-layout"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.weather"
|
||||
},
|
||||
{
|
||||
"id": "kb2uka.port-doctor"
|
||||
},
|
||||
{
|
||||
"id": "akshar.radio-atlas"
|
||||
},
|
||||
{
|
||||
"id": "omaplug"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.system-update"
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"id": "omarchy.tray"
|
||||
},
|
||||
{
|
||||
"id": "io.github.thisisgm.omapods"
|
||||
},
|
||||
{
|
||||
"id": "crmne.hyprmoncfg"
|
||||
},
|
||||
{
|
||||
"id": "ilyazar.btop"
|
||||
},
|
||||
{
|
||||
"id": "vstoms.netbird"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.agents"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.bluetooth"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.network"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.audio"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.monitor"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.power"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": "indie.herdr-hud"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[font]
|
||||
base-size = 10
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"autoupdate": false
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/bin/bash
|
||||
# Apply this repo's Omarchy customizations to the machine it runs on.
|
||||
#
|
||||
# Safe to re-run: every step is a no-op when the target already matches, and any
|
||||
# file it would overwrite is backed up to <file>.bak.<epoch> first.
|
||||
#
|
||||
# ./install.sh # everything
|
||||
# ./install.sh config themes # only the named steps
|
||||
# ./install.sh --list # show the steps
|
||||
# ./install.sh --dry-run # print what would change, touch nothing
|
||||
#
|
||||
# Steps: packages config themes plugins bin services system
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STAMP="$(date +%s)"
|
||||
DRY=0
|
||||
STEPS=(packages config themes plugins bin services system)
|
||||
|
||||
c_ok=$'\033[32m'; c_skip=$'\033[2m'; c_warn=$'\033[33m'; c_err=$'\033[31m'; c_hd=$'\033[1m'; c_off=$'\033[0m'
|
||||
[[ -t 1 ]] || { c_ok=; c_skip=; c_warn=; c_err=; c_hd=; c_off=; }
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
head_() { printf '\n%s==> %s%s\n' "$c_hd" "$*" "$c_off"; }
|
||||
ok() { printf ' %s+%s %s\n' "$c_ok" "$c_off" "$*"; }
|
||||
skip() { printf ' %s= %s%s\n' "$c_skip" "$*" "$c_off"; }
|
||||
warn() { printf ' %s! %s%s\n' "$c_warn" "$*" "$c_off"; }
|
||||
err() { printf ' %sx %s%s\n' "$c_err" "$*" "$c_off"; }
|
||||
|
||||
run() { if (( DRY )); then printf ' %s? would: %s%s\n' "$c_skip" "$*" "$c_off"; else "$@"; fi; }
|
||||
|
||||
# install_file <src> <dest> [mode] -- copy only when content differs, backing up first
|
||||
install_file() {
|
||||
local src=$1 dest=$2 mode=${3:-}
|
||||
local rel=${dest/#"$HOME"/\~}
|
||||
if [[ -e $dest ]] && cmp -s "$src" "$dest"; then
|
||||
skip "$rel"
|
||||
return 0
|
||||
fi
|
||||
if (( DRY )); then
|
||||
printf ' %s? would write %s%s\n' "$c_skip" "$rel" "$c_off"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname -- "$dest")"
|
||||
if [[ -e $dest ]]; then
|
||||
cp -a "$dest" "$dest.bak.$STAMP"
|
||||
cp "$src" "$dest"
|
||||
[[ -n $mode ]] && chmod "$mode" "$dest"
|
||||
ok "$rel (previous saved as $(basename -- "$dest").bak.$STAMP)"
|
||||
else
|
||||
cp "$src" "$dest"
|
||||
[[ -n $mode ]] && chmod "$mode" "$dest"
|
||||
ok "$rel"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- packages
|
||||
step_packages() {
|
||||
head_ "Packages"
|
||||
command -v omarchy >/dev/null || { err "omarchy not on PATH - is this an Omarchy system?"; return 1; }
|
||||
|
||||
local section="" repo=() aur=() optional=()
|
||||
while IFS= read -r line; do
|
||||
line=${line%%#*}; line=${line//[[:space:]]/}
|
||||
[[ -z $line ]] && continue
|
||||
case $line in
|
||||
'[repo]') section=repo; continue ;;
|
||||
'[aur]') section=aur; continue ;;
|
||||
'[optional]') section=optional; continue ;;
|
||||
esac
|
||||
case $section in
|
||||
repo) repo+=("$line") ;;
|
||||
aur) aur+=("$line") ;;
|
||||
optional) optional+=("$line") ;;
|
||||
esac
|
||||
done < "$REPO/packages.txt"
|
||||
|
||||
local missing=()
|
||||
for p in "${repo[@]}"; do pacman -Qq "$p" &>/dev/null || missing+=("$p"); done
|
||||
if (( ${#missing[@]} )); then
|
||||
say " installing from repos: ${missing[*]}"
|
||||
run omarchy pkg add "${missing[@]}" || err "some repo packages failed"
|
||||
else
|
||||
skip "all ${#repo[@]} repo packages present"
|
||||
fi
|
||||
|
||||
missing=()
|
||||
for p in "${aur[@]}"; do pacman -Qq "$p" &>/dev/null || missing+=("$p"); done
|
||||
if (( ${#missing[@]} )); then
|
||||
say " installing from AUR: ${missing[*]}"
|
||||
run omarchy pkg aur add "${missing[@]}" || err "some AUR packages failed"
|
||||
else
|
||||
skip "all ${#aur[@]} AUR packages present"
|
||||
fi
|
||||
|
||||
for p in "${optional[@]}"; do
|
||||
pacman -Qq "$p" &>/dev/null || warn "optional, not installed: $p (omarchy pkg add $p)"
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- config
|
||||
step_config() {
|
||||
head_ "Config files -> ~/.config"
|
||||
local src rel
|
||||
while IFS= read -r -d '' src; do
|
||||
rel=${src#"$REPO/config/"}
|
||||
install_file "$src" "$HOME/.config/$rel"
|
||||
done < <(find "$REPO/config" -type f -print0 | sort -z)
|
||||
|
||||
if (( ! DRY )) && command -v hyprctl >/dev/null && [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then
|
||||
hyprctl reload >/dev/null 2>&1
|
||||
local errs; errs=$(hyprctl configerrors 2>/dev/null)
|
||||
if [[ -n $errs && $errs != "no errors"* ]]; then
|
||||
err "Hyprland reported config errors:"; printf '%s\n' "$errs" | sed 's/^/ /'
|
||||
else
|
||||
ok "hyprctl reload - no config errors"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- themes
|
||||
step_themes() {
|
||||
head_ "Themes -> ~/.config/omarchy/themes"
|
||||
local t name dest
|
||||
for t in "$REPO"/themes/*/; do
|
||||
[[ -d $t ]] || continue
|
||||
name=$(basename -- "$t")
|
||||
dest="$HOME/.config/omarchy/themes/$name"
|
||||
if [[ -d $dest ]] && diff -rq "$t" "$dest" >/dev/null 2>&1; then
|
||||
skip "$name"
|
||||
else
|
||||
run mkdir -p "$dest"
|
||||
run cp -r "$t." "$dest/"
|
||||
ok "$name"
|
||||
fi
|
||||
done
|
||||
say ""
|
||||
say " Themes managed by aether (not in this repo - reinstall with \`aether\`):"
|
||||
say " pink-gt3rs porsche-cayman porsche-rain"
|
||||
say " Apply one with: omarchy theme set <name>"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- plugins
|
||||
step_plugins() {
|
||||
head_ "Shell plugins -> ~/.config/omarchy/plugins"
|
||||
local dir="$HOME/.config/omarchy/plugins"
|
||||
[[ -d $dir ]] || run mkdir -p "$dir"
|
||||
|
||||
local id url sha p
|
||||
while IFS=$'\t' read -r id url sha; do
|
||||
[[ -z ${id:-} || $id == \#* ]] && continue
|
||||
p="$dir/$id"
|
||||
if [[ -d $p/.git ]]; then
|
||||
if [[ $(git -C "$p" rev-parse HEAD 2>/dev/null) == "$sha" ]]; then
|
||||
skip "$id @ ${sha:0:7}"
|
||||
continue
|
||||
fi
|
||||
if [[ -n $(git -C "$p" status --porcelain 2>/dev/null) ]]; then
|
||||
warn "$id has local changes - leaving it alone (wanted ${sha:0:7})"
|
||||
continue
|
||||
fi
|
||||
run git -C "$p" fetch --quiet origin
|
||||
run git -C "$p" checkout --quiet "$sha" && ok "$id -> ${sha:0:7}"
|
||||
else
|
||||
if (( DRY )); then
|
||||
printf ' %s? would clone %s -> %s%s\n' "$c_skip" "$id" "${sha:0:7}" "$c_off"
|
||||
continue
|
||||
fi
|
||||
git clone --quiet "$url" "$p" || { err "clone failed: $id ($url)"; continue; }
|
||||
git -C "$p" checkout --quiet "$sha" || warn "$id: sha $sha not found, left on default branch"
|
||||
ok "$id @ ${sha:0:7}"
|
||||
fi
|
||||
done < "$REPO/plugins.txt"
|
||||
|
||||
apply_herdr_hud
|
||||
apply_worktree_patch io.github.tallsam.navbar-cat
|
||||
|
||||
say ""
|
||||
say " Bar placement lives in config/omarchy/shell.json, already installed by the"
|
||||
say " config step. The shell hot-reloads it; \`omarchy restart shell\` if not."
|
||||
}
|
||||
|
||||
# indie.herdr-hud carries 10 commits that intentionally never went to the
|
||||
# upstream PR, plus an uncommitted working tree. Both are replayed here.
|
||||
apply_herdr_hud() {
|
||||
local p="$HOME/.config/omarchy/plugins/indie.herdr-hud"
|
||||
local bundle="$REPO/plugin-patches/indie.herdr-hud.bundle"
|
||||
[[ -d $p/.git && -f $bundle ]] || return 0
|
||||
|
||||
if git -C "$p" rev-parse --verify --quiet local >/dev/null; then
|
||||
skip "indie.herdr-hud: 'local' branch already present"
|
||||
elif (( DRY )); then
|
||||
printf ' %s? would replay the herdr-hud local branch%s\n' "$c_skip" "$c_off"
|
||||
return 0
|
||||
else
|
||||
git -C "$p" fetch --quiet "$bundle" 'refs/heads/local:refs/heads/local' \
|
||||
&& { git -C "$p" checkout --quiet local && ok "indie.herdr-hud: replayed 'local' branch"; } \
|
||||
|| warn "indie.herdr-hud: could not replay the local branch from the bundle"
|
||||
fi
|
||||
apply_worktree_patch indie.herdr-hud
|
||||
install_file "$REPO/plugin-patches/indie.herdr-hud--bin--herdr-agents" "$p/bin/herdr-agents" 755
|
||||
}
|
||||
|
||||
apply_worktree_patch() {
|
||||
local id=$1
|
||||
local p="$HOME/.config/omarchy/plugins/$id"
|
||||
local patch="$REPO/plugin-patches/$id-worktree.patch"
|
||||
[[ -d $p/.git && -s $patch ]] || return 0
|
||||
if git -C "$p" apply --reverse --check "$patch" &>/dev/null; then
|
||||
skip "$id: working-tree patch already applied"
|
||||
elif (( DRY )); then
|
||||
printf ' %s? would apply %s working-tree patch%s\n' "$c_skip" "$id" "$c_off"
|
||||
elif git -C "$p" apply --3way "$patch" &>/dev/null; then
|
||||
ok "$id: applied working-tree patch"
|
||||
else
|
||||
warn "$id: working-tree patch did not apply cleanly - see $patch"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- bin
|
||||
step_bin() {
|
||||
head_ "Scripts -> ~/.local/bin"
|
||||
local f
|
||||
for f in "$REPO"/bin/*; do
|
||||
[[ -f $f ]] || continue
|
||||
install_file "$f" "$HOME/.local/bin/$(basename -- "$f")" 755
|
||||
done
|
||||
case ":$PATH:" in
|
||||
*":$HOME/.local/bin:"*) ;;
|
||||
*) warn "~/.local/bin is not on PATH" ;;
|
||||
esac
|
||||
say ""
|
||||
say " fix-stuck-displays needs passwordless sudo for 'tee /sys/class/drm/*/status'."
|
||||
say " omarchy-handoff needs a peer and a paired fingerprint before it does anything:"
|
||||
say " omarchy-handoff peer <hostname> <ip> then omarchy-handoff doctor"
|
||||
say " Its config (~/.config/lan-mouse/) holds a private key and per-machine"
|
||||
say " fingerprints, so it is not in this repo by design."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- services
|
||||
step_services() {
|
||||
head_ "systemd user units"
|
||||
local f name changed=0
|
||||
for f in "$REPO"/systemd/user/*.service; do
|
||||
[[ -f $f ]] || continue
|
||||
name=$(basename -- "$f")
|
||||
local dest="$HOME/.config/systemd/user/$name"
|
||||
[[ -e $dest ]] && cmp -s "$f" "$dest" || changed=1
|
||||
install_file "$f" "$dest"
|
||||
done
|
||||
(( changed )) && run systemctl --user daemon-reload
|
||||
for f in "$REPO"/systemd/user/*.service; do
|
||||
[[ -f $f ]] || continue
|
||||
name=$(basename -- "$f")
|
||||
if systemctl --user is-enabled --quiet "$name" 2>/dev/null; then
|
||||
skip "$name already enabled"
|
||||
else
|
||||
run systemctl --user enable "$name" && ok "enabled $name"
|
||||
fi
|
||||
done
|
||||
say ""
|
||||
say " Both are WantedBy=graphical-session.target, so they start with Hyprland"
|
||||
say " rather than now. 'systemctl --user start <unit>' to bring one up early."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- system
|
||||
step_system() {
|
||||
head_ "System drop-ins (needs sudo)"
|
||||
local src rel dest
|
||||
while IFS= read -r -d '' src; do
|
||||
rel=${src#"$REPO/system/"}
|
||||
dest="/etc/$rel"
|
||||
if [[ -e $dest ]] && cmp -s "$src" "$dest"; then
|
||||
skip "$dest"
|
||||
continue
|
||||
fi
|
||||
if (( DRY )); then
|
||||
printf ' %s? would write %s%s\n' "$c_skip" "$dest" "$c_off"
|
||||
continue
|
||||
fi
|
||||
sudo install -D -m 0644 "$src" "$dest" && ok "$dest"
|
||||
done < <(find "$REPO/system" -type f -print0 | sort -z)
|
||||
|
||||
if (( ! DRY )); then
|
||||
sudo sysctl --system >/dev/null 2>&1 && ok "sysctl reloaded"
|
||||
say ""
|
||||
say " hid_apple.conf (fnmode=2: F-keys act as F-keys) only takes effect once the"
|
||||
say " initramfs is rebuilt or the module reloads:"
|
||||
say " sudo mkinitcpio -P # or: sudo modprobe -r hid_apple && sudo modprobe hid_apple"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
main() {
|
||||
local want=()
|
||||
for a in "$@"; do
|
||||
case $a in
|
||||
--dry-run|-n) DRY=1 ;;
|
||||
--list|-l) printf '%s\n' "${STEPS[@]}"; exit 0 ;;
|
||||
-h|--help) sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-*) err "unknown option: $a"; exit 2 ;;
|
||||
*)
|
||||
if [[ " ${STEPS[*]} " == *" $a "* ]]; then want+=("$a")
|
||||
else err "unknown step: $a (one of: ${STEPS[*]})"; exit 2; fi ;;
|
||||
esac
|
||||
done
|
||||
(( ${#want[@]} )) || want=("${STEPS[@]}")
|
||||
|
||||
say "${c_hd}omarchy-setup${c_off} repo=$REPO"
|
||||
(( DRY )) && warn "dry run - nothing will be changed"
|
||||
command -v omarchy >/dev/null || warn "omarchy not found; this looks like a non-Omarchy host"
|
||||
|
||||
for s in "${want[@]}"; do "step_$s"; done
|
||||
|
||||
head_ "Done"
|
||||
say " Re-running is safe. Backups from this run are suffixed .bak.$STAMP"
|
||||
say " Pull live changes back into the repo with ./capture.sh"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,56 @@
|
||||
# Packages this laptop has beyond the Omarchy 4.x baseline.
|
||||
#
|
||||
# Computed as: `pacman -Qqe` minus the omarchy meta-package's dependencies and
|
||||
# install/omarchy-base.packages. Lines are "name # why". Blank lines and
|
||||
# comments are ignored. Section markers drive install.sh --packages:
|
||||
# [repo] official repos + the omarchy repo -> omarchy pkg add
|
||||
# [aur] AUR only -> omarchy pkg aur add
|
||||
# [optional] installed here, not required for the desktop to work
|
||||
#
|
||||
# Deliberately NOT listed: base, base-devel, sudo, mkinitcpio, linux-firmware,
|
||||
# efibootmgr, devtools, cmake, ninja (toolchain/base, present on any Omarchy
|
||||
# install or pulled in as a dependency), and the remote-access stack
|
||||
# (wayvnc, sunshine, freerdp, remmina, omarchy-remote-desktop-backend,
|
||||
# pipewire-debug) which is out of this repo's scope.
|
||||
|
||||
[repo]
|
||||
# --- hardware: this laptop is Intel + an Anker USB-C dock ---
|
||||
linux-omarchy # kernel this install actually boots
|
||||
linux-omarchy-headers # needed to build out-of-tree modules against it
|
||||
intel-ucode # CPU microcode
|
||||
vulkan-intel # Intel Vulkan driver
|
||||
lib32-vulkan-intel # 32-bit Vulkan, for Steam/Proton
|
||||
sof-firmware # Sound Open Firmware - required for this laptop's audio
|
||||
thermald # Intel thermal daemon; without it the CPU throttles hard
|
||||
zram-generator # compressed swap in RAM
|
||||
|
||||
# --- audio: full PipeWire stack with the Pulse/JACK/ALSA shims ---
|
||||
pipewire-pulse
|
||||
pipewire-alsa
|
||||
pipewire-jack
|
||||
libpulse
|
||||
gst-plugin-pipewire
|
||||
|
||||
# --- desktop / input ---
|
||||
lan-mouse # keyboard+mouse sharing driven by bin/omarchy-handoff
|
||||
python-evdev # lan-mouse / input tooling dependency
|
||||
wayland-utils # wayland-info, used when debugging outputs
|
||||
ghostty # terminal (alongside Omarchy's default)
|
||||
qmltermwidget # embedded terminal widget; indie.herdr-hud needs it
|
||||
qt6-connectivity # Bluetooth/QML bits for the omapods plugin
|
||||
qt6-tools # qmllint etc. when editing plugin QML
|
||||
openbsd-netcat # nc for kb2uka.port-doctor checks
|
||||
|
||||
# --- apps ---
|
||||
1password
|
||||
1password-cli # `op`, which the dotfiles repo's secrets module uses
|
||||
visual-studio-code-bin
|
||||
bun-bin
|
||||
|
||||
[aur]
|
||||
hyprmoncfg-bin # display profile daemon; pairs with crmne.hyprmoncfg plugin
|
||||
netbird-bin # NetBird client for the covert.vpn private network
|
||||
voxtype-bin # push-to-talk voice-to-text (voxtype.service)
|
||||
|
||||
[optional]
|
||||
steam # games; pulls a large 32-bit dependency tree
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Start and close Herdr agents for the HUD. herdr-monitor stays read-only."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TIMEOUT = 15
|
||||
|
||||
# Herdr's supported kinds (`herdr agent start --kind`), with display names.
|
||||
KINDS = {
|
||||
"claude": "Claude Code", "codex": "Codex", "gemini": "Gemini CLI", "cursor": "Cursor",
|
||||
"opencode": "opencode", "copilot": "Copilot CLI", "pi": "pi", "omp": "omp", "grok": "Grok",
|
||||
"hermes": "Hermes", "amp": "Amp", "droid": "Droid", "cline": "Cline", "kimi": "Kimi",
|
||||
"kiro": "Kiro", "qwen": "Qwen Code", "kilo": "Kilo", "devin": "Devin", "agy": "Antigravity",
|
||||
"mastracode": "Mastra Code", "qodercli": "Qoder", "maki": "Maki",
|
||||
}
|
||||
EXECUTABLE = {"cursor": "cursor-agent"}
|
||||
|
||||
|
||||
class AgentsError(RuntimeError):
|
||||
def __init__(self, message: str, code: str = ""):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def herdr(*args: str, timeout: float = TIMEOUT) -> dict:
|
||||
executable = shutil.which("herdr")
|
||||
if not executable:
|
||||
raise AgentsError("Herdr is not installed or not on PATH")
|
||||
try:
|
||||
done = subprocess.run([executable, *args], capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise AgentsError("Herdr did not respond in time") from error
|
||||
if done.returncode != 0:
|
||||
# Herdr prints {"error": {"code", "message"}} on stderr.
|
||||
try:
|
||||
error = json.loads(done.stderr).get("error") or {}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
error = {}
|
||||
raise AgentsError(error.get("message") or done.stderr.strip() or f"herdr {args[0]} failed",
|
||||
error.get("code", ""))
|
||||
return json.loads(done.stdout or "{}").get("result", {})
|
||||
|
||||
|
||||
def installed_kinds() -> list[str]:
|
||||
# Agents run in Herdr's login shell, which may have PATH entries (mise, npm)
|
||||
# that the desktop shell lacks, so ask that shell.
|
||||
executables = [EXECUTABLE.get(kind, kind) for kind in KINDS]
|
||||
script = "for e in " + " ".join(executables) + '; do command -v "$e" >/dev/null 2>&1 && echo "$e"; done'
|
||||
try:
|
||||
done = subprocess.run([os.environ.get("SHELL", "/bin/bash"), "-lic", script],
|
||||
capture_output=True, text=True, timeout=TIMEOUT, stdin=subprocess.DEVNULL)
|
||||
found = set(done.stdout.split())
|
||||
except subprocess.TimeoutExpired:
|
||||
found = {e for e in executables if shutil.which(e)}
|
||||
return [kind for kind in KINDS if EXECUTABLE.get(kind, kind) in found]
|
||||
|
||||
|
||||
def options() -> dict:
|
||||
agents = herdr("agent", "list").get("agents", [])
|
||||
spaces = herdr("workspace", "list").get("workspaces", [])
|
||||
folders = {str(a.get("cwd")) for a in agents if a.get("cwd")}
|
||||
work = Path.home() / "Work"
|
||||
if work.is_dir():
|
||||
folders |= {str(p) for p in work.iterdir() if p.is_dir() and not p.name.startswith(".")}
|
||||
# A workspace's folder is where its first agent runs; used to pick a default.
|
||||
space_cwd = {}
|
||||
for agent in agents:
|
||||
space_cwd.setdefault(str(agent.get("workspace_id", "")), agent.get("cwd"))
|
||||
return {
|
||||
"kinds": [{"kind": kind, "label": KINDS[kind]} for kind in installed_kinds()],
|
||||
"folders": sorted(folders),
|
||||
"workspaces": [{"id": s.get("workspace_id"), "label": s.get("label") or s.get("workspace_id"),
|
||||
"cwd": space_cwd.get(str(s.get("workspace_id")))} for s in spaces],
|
||||
}
|
||||
|
||||
|
||||
def slug(text: str, fallback: str) -> str:
|
||||
# Herdr agent names: [a-z][a-z0-9_-]{0,31}, unique among live agents.
|
||||
base = re.sub(r"[^a-z0-9_-]+", "-", text.lower())
|
||||
base = re.sub(r"^[^a-z]+|-+$", "", base)[:24]
|
||||
return base or fallback
|
||||
|
||||
|
||||
def start(request: dict) -> dict:
|
||||
kind = str(request.get("kind", ""))
|
||||
if kind not in installed_kinds():
|
||||
raise AgentsError(f"{KINDS.get(kind, kind)} isn't installed")
|
||||
home = Path.home().resolve()
|
||||
folder = Path(os.path.expanduser(str(request.get("cwd", "")).strip())).resolve()
|
||||
if not folder.is_dir():
|
||||
raise AgentsError("That folder doesn't exist")
|
||||
if folder != home and home not in folder.parents:
|
||||
raise AgentsError("Pick a folder in your home directory")
|
||||
name = str(request.get("name", "")).strip()[:60]
|
||||
prompt = str(request.get("prompt", "")).strip()
|
||||
workspace = str(request.get("workspace", "") or "")
|
||||
label = (name or folder.name)[:24]
|
||||
|
||||
if workspace:
|
||||
created = herdr("tab", "create", "--workspace", workspace, "--cwd", str(folder), "--label", label)
|
||||
else:
|
||||
created = herdr("workspace", "create", "--cwd", str(folder), "--label", folder.name, "--no-focus")
|
||||
herdr("tab", "rename", created["tab"]["tab_id"], label)
|
||||
pane = created["root_pane"]["pane_id"]
|
||||
tab = created["tab"]["tab_id"]
|
||||
|
||||
taken = {a.get("name") for a in herdr("agent", "list").get("agents", [])}
|
||||
base = slug(name or folder.name, kind)
|
||||
agent_name, n = base, 2
|
||||
while agent_name in taken:
|
||||
agent_name, n = f"{base[:28]}-{n}", n + 1
|
||||
|
||||
# Claude shows --name as its terminal title and session name.
|
||||
args = ["--", "--name", name] if kind == "claude" and name else []
|
||||
time.sleep(0.8) # let the shell reach its prompt
|
||||
try:
|
||||
herdr("agent", "start", agent_name, "--kind", kind, "--pane", pane, "--timeout", "45000", *args, timeout=50)
|
||||
except AgentsError as error:
|
||||
# Blocked at startup (a trust-this-folder question, say) still counts:
|
||||
# the HUD terminal can answer it. Otherwise close the empty tab.
|
||||
if error.code == "agent_not_ready":
|
||||
return {"pane_id": pane, "blocked": True}
|
||||
try:
|
||||
herdr("tab", "close", tab)
|
||||
except AgentsError:
|
||||
pass
|
||||
raise AgentsError(f"{KINDS[kind]} didn't start: {error}") from None
|
||||
if prompt:
|
||||
# Herdr reports the start a moment before it takes prompts for the name.
|
||||
for attempt in range(20):
|
||||
try:
|
||||
herdr("agent", "prompt", pane, prompt)
|
||||
break
|
||||
except AgentsError as error:
|
||||
if error.code != "agent_not_ready" or attempt == 19:
|
||||
return {"pane_id": pane, "blocked": False, "prompt_error": str(error)}
|
||||
time.sleep(0.5)
|
||||
return {"pane_id": pane, "blocked": False}
|
||||
|
||||
|
||||
def claude_attach_id(pane: str) -> str:
|
||||
info = herdr("pane", "process-info", "--pane", pane).get("process_info", {})
|
||||
for process in info.get("foreground_processes", []):
|
||||
argv = process.get("argv") or []
|
||||
if len(argv) >= 3 and os.path.basename(argv[0]) == "claude" and argv[1] == "attach":
|
||||
return argv[2]
|
||||
return ""
|
||||
|
||||
|
||||
def close(pane: str) -> dict:
|
||||
# A `claude attach` pane only views a background session; closing the pane
|
||||
# would leave the session running, so stop it (its conversation is kept).
|
||||
session = ""
|
||||
try:
|
||||
session = claude_attach_id(pane)
|
||||
except AgentsError:
|
||||
pass
|
||||
if session:
|
||||
claude = shutil.which("claude") or str(Path.home() / ".local/share/mise/shims/claude")
|
||||
done = subprocess.run([claude, "stop", session], capture_output=True, text=True, timeout=TIMEOUT)
|
||||
if done.returncode != 0:
|
||||
raise AgentsError((done.stderr or done.stdout).strip().splitlines()[0] if (done.stderr or done.stdout).strip()
|
||||
else "claude stop failed")
|
||||
herdr("pane", "close", pane)
|
||||
return {"closed": pane, "stopped_session": session}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if argv[1:] == ["options"]:
|
||||
result = options()
|
||||
elif len(argv) == 3 and argv[1] == "start":
|
||||
result = start(json.loads(argv[2]))
|
||||
elif len(argv) == 3 and argv[1] == "close":
|
||||
result = close(argv[2])
|
||||
else:
|
||||
raise AgentsError("usage: herdr-agents options|start JSON|close PANE")
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main(sys.argv))
|
||||
except (AgentsError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
|
||||
print(str(error)[:4096], file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,861 @@
|
||||
diff --git i/HerdrHud.qml w/HerdrHud.qml
|
||||
index 018311d..d01449c 100644
|
||||
--- i/HerdrHud.qml
|
||||
+++ w/HerdrHud.qml
|
||||
@@ -7,6 +7,7 @@ import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Ui as Ui
|
||||
import "Alerts.js" as Alerts
|
||||
+import "Roster.js" as Roster
|
||||
import "."
|
||||
|
||||
Item {
|
||||
@@ -21,12 +22,13 @@ Item {
|
||||
readonly property string pluginId: (manifest && manifest.id) || "indie.herdr-hud"
|
||||
readonly property string pluginDir: (manifest && manifest.__sourceDir) || ""
|
||||
readonly property string bridgePath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-monitor")).replace(/^file:\/\//, ""))
|
||||
+ readonly property string agentsPath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-agents")).replace(/^file:\/\//, ""))
|
||||
readonly property string homeDir: Quickshell.env("HOME")
|
||||
readonly property string configDir: homeDir + "/.config/herdr-hud-native"
|
||||
readonly property string statePath: configDir + "/state.json"
|
||||
|
||||
property bool opened: false
|
||||
- onOpenedChanged: if (opened) clearAlerts()
|
||||
+ onOpenedChanged: if (opened) { clearAlerts(); resort() }
|
||||
property bool overlayVisible: true
|
||||
property bool openingRequested: false
|
||||
property bool demoMode: false
|
||||
@@ -35,10 +37,136 @@ Item {
|
||||
property var agents: []
|
||||
property string selectedPane: ""
|
||||
property var unread: ({})
|
||||
- // Keep Herdr's own order (workspace order). Re-sorting by attention moved
|
||||
- // agents as soon as they were selected or changed status, which sent
|
||||
- // prompts to the wrong agent; status glyphs and badges show attention.
|
||||
- readonly property var sortedAgents: agents
|
||||
+ // Herdr's own order (workspace order) by default. Re-sorting live by
|
||||
+ // attention moved agents as soon as they were selected or changed status,
|
||||
+ // which sent prompts to the wrong agent. So other sort orders are worked out
|
||||
+ // only when the panel opens, the sort changes, or agents come or go; a row
|
||||
+ // never moves while you look at it. Status glyphs and badges show attention.
|
||||
+ property string sortMode: "herdr"
|
||||
+ property var rosterOrder: []
|
||||
+ property var activity: ({})
|
||||
+ readonly property var sortedAgents: sortMode === "herdr" ? agents : Roster.arrange(agents, rosterOrder)
|
||||
+
|
||||
+ function resort() { rosterOrder = Roster.order(agents, sortMode, activity) }
|
||||
+
|
||||
+ function cycleSort(step) {
|
||||
+ var index = Roster.SORTS.indexOf(sortMode)
|
||||
+ sortMode = Roster.SORTS[(index + step + Roster.SORTS.length) % Roster.SORTS.length]
|
||||
+ resort()
|
||||
+ saveState()
|
||||
+ }
|
||||
+
|
||||
+ // Starting and closing agents goes through bin/herdr-agents.
|
||||
+ property bool newAgentOpen: false
|
||||
+ property var spawnOptions: null
|
||||
+ property string newKind: "claude"
|
||||
+ property string newFolder: ""
|
||||
+ property string newWorkspace: ""
|
||||
+ property bool newAgentBusy: false
|
||||
+ property string newAgentError: ""
|
||||
+ property string lastKind: ""
|
||||
+ property string lastFolder: ""
|
||||
+ property string pendingPane: ""
|
||||
+ property string confirmClosePane: ""
|
||||
+ property string closingPane: ""
|
||||
+
|
||||
+ function tildePath(path) {
|
||||
+ return homeDir && String(path).indexOf(homeDir) === 0 ? "~" + String(path).slice(homeDir.length) : String(path)
|
||||
+ }
|
||||
+
|
||||
+ function expandPath(path) {
|
||||
+ var text = String(path || "").trim()
|
||||
+ return text === "~" || text.indexOf("~/") === 0 ? homeDir + text.slice(1) : text
|
||||
+ }
|
||||
+
|
||||
+ // IPC entry point too: omarchy-shell shell call indie.herdr-hud newAgent '{}'
|
||||
+ function newAgent(_arg) {
|
||||
+ if (!(opened && overlayVisible)) open("{}")
|
||||
+ openNewAgent()
|
||||
+ }
|
||||
+
|
||||
+ function openNewAgent() {
|
||||
+ if (demoMode || optionsProc.running) return
|
||||
+ newAgentOpen = true
|
||||
+ newAgentError = ""
|
||||
+ spawnOptions = null
|
||||
+ optionsProc.exec(["python3", agentsPath, "options"])
|
||||
+ }
|
||||
+
|
||||
+ function applySpawnOptions(raw, error, exitCode) {
|
||||
+ if (exitCode !== 0) {
|
||||
+ newAgentError = String(error || "Couldn't list the agents you can start.").trim()
|
||||
+ return
|
||||
+ }
|
||||
+ try {
|
||||
+ var options = JSON.parse(String(raw || "{}"))
|
||||
+ var kinds = options.kinds || []
|
||||
+ newKind = kinds.some(function(k) { return k.kind === lastKind }) ? lastKind
|
||||
+ : kinds.some(function(k) { return k.kind === "claude" }) ? "claude" : (kinds.length ? kinds[0].kind : "")
|
||||
+ // Pick the folder before publishing the options; the form fills in from both.
|
||||
+ setNewFolder(lastFolder || (options.folders || [])[0] || homeDir, options)
|
||||
+ spawnOptions = options
|
||||
+ } catch (parseError) {
|
||||
+ newAgentError = "Herdr returned an unreadable response."
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Default to the workspace already working in that folder, else a new one.
|
||||
+ function setNewFolder(path, options) {
|
||||
+ newFolder = path
|
||||
+ var dir = expandPath(path)
|
||||
+ var known = options || spawnOptions
|
||||
+ var match = (known ? known.workspaces : []).find(function(w) { return w.cwd === dir })
|
||||
+ newWorkspace = match ? String(match.id) : ""
|
||||
+ }
|
||||
+
|
||||
+ function folderSuggestions(text) {
|
||||
+ if (!spawnOptions) return []
|
||||
+ var dir = expandPath(text)
|
||||
+ var query = dir.toLowerCase()
|
||||
+ return spawnOptions.folders.filter(function(f) { return f !== dir && f.toLowerCase().indexOf(query) >= 0 }).slice(0, 6)
|
||||
+ }
|
||||
+
|
||||
+ function startAgent(name, prompt) {
|
||||
+ if (agentsProc.running || !newKind) return
|
||||
+ if (!expandPath(newFolder)) { newAgentError = "Pick a folder."; return }
|
||||
+ newAgentBusy = true
|
||||
+ newAgentError = ""
|
||||
+ lastKind = newKind
|
||||
+ lastFolder = expandPath(newFolder)
|
||||
+ saveState()
|
||||
+ agentsProc.action = "start"
|
||||
+ agentsProc.exec(["python3", agentsPath, "start", JSON.stringify({
|
||||
+ kind: newKind, cwd: expandPath(newFolder), workspace: newWorkspace, name: String(name || ""), prompt: String(prompt || "")
|
||||
+ })])
|
||||
+ }
|
||||
+
|
||||
+ function closeAgent(pane) {
|
||||
+ if (agentsProc.running) return
|
||||
+ confirmClosePane = ""
|
||||
+ closingPane = pane
|
||||
+ agentsProc.action = "close"
|
||||
+ agentsProc.exec(["python3", agentsPath, "close", pane])
|
||||
+ }
|
||||
+
|
||||
+ function applyAgentsAction(action, raw, error, exitCode) {
|
||||
+ var result = ({})
|
||||
+ try { result = JSON.parse(String(raw || "{}")) } catch (parseError) { result = ({}) }
|
||||
+ var message = String(error || "").trim()
|
||||
+ if (action === "start") {
|
||||
+ newAgentBusy = false
|
||||
+ if (exitCode !== 0) { newAgentError = message || "The agent didn't start."; return }
|
||||
+ newAgentOpen = false
|
||||
+ pendingPane = String(result.pane_id || "")
|
||||
+ noticeText = result.blocked ? "It's asking something before it starts. Answer it in the terminal."
|
||||
+ : result.prompt_error ? "Started, but the first message didn't go through: " + result.prompt_error : ""
|
||||
+ } else {
|
||||
+ closingPane = ""
|
||||
+ if (exitCode !== 0) noticeText = message || "Couldn't close that agent."
|
||||
+ else noticeText = result.stopped_session ? "Stopped the Claude background session. Its conversation is kept." : ""
|
||||
+ }
|
||||
+ refreshRoster()
|
||||
+ }
|
||||
property var lastSequence: ({})
|
||||
property var alertQueue: []
|
||||
property bool alertsEnabled: true
|
||||
@@ -190,6 +318,9 @@ Item {
|
||||
if (Number(parsed.rosterWidth) > 0) rosterWidth = clamp(Number(parsed.rosterWidth), 150, 330)
|
||||
if (Number(parsed.panelWidth) > 0) panelWidth = Math.max(minPanelWidth, Number(parsed.panelWidth))
|
||||
if (Number(parsed.panelHeight) > 0) panelHeight = Math.max(minPanelHeight, Number(parsed.panelHeight))
|
||||
+ if (Roster.SORTS.indexOf(parsed.sortMode) >= 0) sortMode = parsed.sortMode
|
||||
+ if (typeof parsed.lastKind === "string") lastKind = parsed.lastKind
|
||||
+ if (typeof parsed.lastFolder === "string") lastFolder = parsed.lastFolder
|
||||
}
|
||||
} catch (error) {
|
||||
positions = ({})
|
||||
@@ -209,7 +340,10 @@ Item {
|
||||
positions: positions,
|
||||
rosterWidth: Math.round(rosterWidth),
|
||||
panelWidth: Math.round(panelWidth),
|
||||
- panelHeight: Math.round(panelHeight)
|
||||
+ panelHeight: Math.round(panelHeight),
|
||||
+ sortMode: sortMode,
|
||||
+ lastKind: lastKind,
|
||||
+ lastFolder: lastFolder
|
||||
}, null, 2) + "\n")
|
||||
}
|
||||
|
||||
@@ -486,9 +620,10 @@ Item {
|
||||
return muted
|
||||
}
|
||||
|
||||
- function agentName(agent) {
|
||||
- if (!agent) return "Agent"
|
||||
- var type = String(agent.agent || "agent")
|
||||
+ function agentName(agent) { return Roster.displayName(agent) }
|
||||
+
|
||||
+ function agentKind(agent) {
|
||||
+ var type = String(agent ? agent.agent || "agent" : "agent")
|
||||
return type.charAt(0).toUpperCase() + type.slice(1)
|
||||
}
|
||||
|
||||
@@ -581,6 +716,7 @@ Item {
|
||||
var nextUnread = cloneObject(unread)
|
||||
var nextSequence = ({})
|
||||
var nextWorkingSince = ({})
|
||||
+ var nextActivity = ({})
|
||||
var live = ({})
|
||||
var previousByPane = ({})
|
||||
for (var p = 0; p < agents.length; p++) previousByPane[String(agents[p].pane_id || "")] = agents[p]
|
||||
@@ -595,13 +731,20 @@ Item {
|
||||
nextWorkingSince[identity] = workingSince[identity] || Date.now()
|
||||
}
|
||||
live[pane] = true
|
||||
+ // Last activity: the session store's last write, else the last status change we saw.
|
||||
+ var statusChanged = !!previousByPane[pane] && String(previousByPane[pane].agent_status || "") !== status
|
||||
+ nextActivity[pane] = Math.max(Number(row.active_at) || 0, activity[pane] || 0, statusChanged ? Date.now() : 0)
|
||||
nextSequence[pane] = sequence === null ? (previousSequence === undefined ? 0 : previousSequence) : sequence
|
||||
if (status === "working" || (opened && pane === selectedPane)) delete nextUnread[pane]
|
||||
else if (isUnreadTransition(previousByPane[pane], row, previousSequence, sequence)) nextUnread[pane] = true
|
||||
}
|
||||
for (var unreadPane in nextUnread) if (!live[unreadPane]) delete nextUnread[unreadPane]
|
||||
var previousAgent = agentForPane(selectedPane)
|
||||
+ var paneSetChanged = rows.length !== agents.length
|
||||
+ || rows.some(function(row) { return !previousByPane[String(row.pane_id || "")] })
|
||||
agents = rows
|
||||
+ activity = nextActivity
|
||||
+ if (!opened || paneSetChanged) resort()
|
||||
workingSince = nextWorkingSince
|
||||
activityNow = Date.now()
|
||||
var currentAgent = agentForPane(selectedPane)
|
||||
@@ -616,7 +759,12 @@ Item {
|
||||
}
|
||||
completed.forEach(function(agent) { root.queueAlert(agent) })
|
||||
errorText = ""
|
||||
- if (!agentForPane(selectedPane)) selectAgent(rows.length ? String(rows[0].pane_id || "") : "")
|
||||
+ if (pendingPane && live[pendingPane]) {
|
||||
+ selectAgent(pendingPane)
|
||||
+ pendingPane = ""
|
||||
+ }
|
||||
+ if (confirmClosePane && !live[confirmClosePane]) confirmClosePane = ""
|
||||
+ if (!agentForPane(selectedPane)) selectAgent(rows.length ? String(sortedAgents[0].pane_id || "") : "")
|
||||
dataRevision++
|
||||
} catch (parseError) {
|
||||
alertBaseline = false
|
||||
@@ -627,6 +775,86 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
+ // A small text button for the roster and the new-agent form.
|
||||
+ component HudChip: Rectangle {
|
||||
+ id: chip
|
||||
+ required property var hud
|
||||
+ property string text: ""
|
||||
+ property bool selected: false
|
||||
+ property bool primary: false
|
||||
+ property bool danger: false
|
||||
+ signal clicked()
|
||||
+ signal rightClicked()
|
||||
+ implicitWidth: chipLabel.implicitWidth + 18
|
||||
+ implicitHeight: 24
|
||||
+ radius: hud.cornerRadius
|
||||
+ opacity: enabled ? 1 : 0.5
|
||||
+ readonly property color tone: danger ? hud.urgent : hud.accent
|
||||
+ color: primary ? tone : selected ? hud.alpha(tone, 0.22)
|
||||
+ : hud.alpha(hud.foreground, chipMouse.containsMouse ? 0.1 : 0.04)
|
||||
+ border.width: 1
|
||||
+ border.color: primary || selected || chipMouse.containsMouse ? tone : hud.alpha(hud.foreground, 0.16)
|
||||
+ Text {
|
||||
+ id: chipLabel
|
||||
+ anchors.centerIn: parent
|
||||
+ text: chip.text
|
||||
+ color: chip.primary ? hud.contrastColor(chip.tone, hud.foreground, hud.background) : chip.selected || chip.danger ? chip.tone : hud.foreground
|
||||
+ font.family: hud.chromeFont
|
||||
+ font.pixelSize: 11
|
||||
+ font.bold: chip.selected || chip.primary
|
||||
+ }
|
||||
+ MouseArea {
|
||||
+ id: chipMouse
|
||||
+ anchors.fill: parent
|
||||
+ hoverEnabled: true
|
||||
+ cursorShape: Qt.PointingHandCursor
|
||||
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
+ onClicked: function(mouse) { mouse.button === Qt.RightButton ? chip.rightClicked() : chip.clicked() }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ component HudField: Rectangle {
|
||||
+ id: field
|
||||
+ required property var hud
|
||||
+ property alias text: input.text
|
||||
+ property string placeholder: ""
|
||||
+ property bool multiline: false
|
||||
+ signal edited()
|
||||
+ signal cancelled()
|
||||
+ signal submitted()
|
||||
+ implicitHeight: multiline ? 70 : 30
|
||||
+ radius: hud.cornerRadius
|
||||
+ color: hud.alpha(hud.foreground, 0.05)
|
||||
+ border.width: 1
|
||||
+ border.color: input.activeFocus ? hud.accent : hud.alpha(hud.foreground, 0.18)
|
||||
+ clip: true
|
||||
+ function focusInput() { input.forceActiveFocus() }
|
||||
+ TextEdit {
|
||||
+ id: input
|
||||
+ anchors.fill: parent
|
||||
+ anchors.margins: 7
|
||||
+ color: hud.foreground
|
||||
+ selectionColor: hud.alpha(hud.accent, 0.4)
|
||||
+ font.family: hud.chromeFont
|
||||
+ font.pixelSize: 13
|
||||
+ wrapMode: field.multiline ? TextEdit.Wrap : TextEdit.NoWrap
|
||||
+ selectByMouse: true
|
||||
+ onTextChanged: field.edited()
|
||||
+ Keys.onEscapePressed: field.cancelled()
|
||||
+ // One-line fields submit on Enter; the message field takes newlines.
|
||||
+ Keys.onReturnPressed: function(event) { if (field.multiline) event.accepted = false; else field.submitted() }
|
||||
+ Keys.onEnterPressed: function(event) { if (field.multiline) event.accepted = false; else field.submitted() }
|
||||
+ Text {
|
||||
+ anchors.fill: parent
|
||||
+ visible: !input.text && !input.activeFocus
|
||||
+ text: field.placeholder
|
||||
+ color: hud.muted
|
||||
+ font: input.font
|
||||
+ elide: Text.ElideRight
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
Component.onCompleted: {
|
||||
mkdirProc.running = true
|
||||
Qt.callLater(root.refreshRoster)
|
||||
@@ -689,6 +917,21 @@ Item {
|
||||
onExited: function(exitCode) { root.applyRoster(rosterOut.text, rosterErr.text, exitCode) }
|
||||
}
|
||||
|
||||
+ Process {
|
||||
+ id: optionsProc
|
||||
+ stdout: StdioCollector { id: optionsOut; waitForEnd: true }
|
||||
+ stderr: StdioCollector { id: optionsErr; waitForEnd: true }
|
||||
+ onExited: function(exitCode) { root.applySpawnOptions(optionsOut.text, optionsErr.text, exitCode) }
|
||||
+ }
|
||||
+
|
||||
+ Process {
|
||||
+ id: agentsProc
|
||||
+ property string action: ""
|
||||
+ stdout: StdioCollector { id: agentsOut; waitForEnd: true }
|
||||
+ stderr: StdioCollector { id: agentsErr; waitForEnd: true }
|
||||
+ onExited: function(exitCode) { root.applyAgentsAction(action, agentsOut.text, agentsErr.text, exitCode) }
|
||||
+ }
|
||||
+
|
||||
property string previewIdentity: ""
|
||||
Process {
|
||||
id: alertPreviewProc
|
||||
@@ -1034,6 +1277,186 @@ Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
+ // New agent: covers the roster and terminal until started or cancelled.
|
||||
+ Rectangle {
|
||||
+ id: newAgentPanel
|
||||
+ anchors.fill: parent
|
||||
+ z: 3
|
||||
+ visible: root.newAgentOpen
|
||||
+ color: root.panelFill
|
||||
+ radius: root.cornerRadius
|
||||
+ border.width: 1
|
||||
+ border.color: root.alpha(root.foreground, 0.12)
|
||||
+ onVisibleChanged: if (visible) Qt.callLater(function() { nameField.focusInput() })
|
||||
+
|
||||
+ Connections {
|
||||
+ target: root
|
||||
+ function onSpawnOptionsChanged() {
|
||||
+ if (!root.spawnOptions) return
|
||||
+ folderField.text = root.tildePath(root.newFolder)
|
||||
+ nameField.text = ""
|
||||
+ promptField.text = ""
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ MouseArea { anchors.fill: parent } // keep clicks off the terminal underneath
|
||||
+
|
||||
+ Flickable {
|
||||
+ anchors.fill: parent
|
||||
+ anchors.margins: 14
|
||||
+ contentHeight: newAgentForm.implicitHeight
|
||||
+ clip: true
|
||||
+ boundsBehavior: Flickable.StopAtBounds
|
||||
+ ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
+
|
||||
+ ColumnLayout {
|
||||
+ id: newAgentForm
|
||||
+ width: parent.width
|
||||
+ spacing: 8
|
||||
+
|
||||
+ Text {
|
||||
+ text: "New agent"
|
||||
+ color: root.accent
|
||||
+ font.family: root.chromeFont
|
||||
+ font.pixelSize: 15
|
||||
+ font.bold: true
|
||||
+ }
|
||||
+ Text {
|
||||
+ Layout.fillWidth: true
|
||||
+ visible: !root.spawnOptions
|
||||
+ text: root.newAgentError || "Loading…"
|
||||
+ color: root.newAgentError ? root.urgent : root.muted
|
||||
+ wrapMode: Text.Wrap
|
||||
+ font.family: root.chromeFont
|
||||
+ font.pixelSize: 12
|
||||
+ }
|
||||
+
|
||||
+ ColumnLayout {
|
||||
+ Layout.fillWidth: true
|
||||
+ visible: !!root.spawnOptions
|
||||
+ spacing: 8
|
||||
+
|
||||
+ Text { text: "AGENT"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||||
+ Flow {
|
||||
+ Layout.fillWidth: true
|
||||
+ spacing: 6
|
||||
+ Repeater {
|
||||
+ model: root.spawnOptions ? root.spawnOptions.kinds : []
|
||||
+ delegate: HudChip {
|
||||
+ required property var modelData
|
||||
+ hud: root
|
||||
+ text: modelData.label
|
||||
+ selected: root.newKind === modelData.kind
|
||||
+ onClicked: root.newKind = modelData.kind
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ Text { text: "FOLDER"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||||
+ HudField {
|
||||
+ id: folderField
|
||||
+ hud: root
|
||||
+ Layout.fillWidth: true
|
||||
+ placeholder: "~/Work/project"
|
||||
+ onEdited: root.setNewFolder(text)
|
||||
+ onCancelled: root.newAgentOpen = false
|
||||
+ onSubmitted: nameField.focusInput()
|
||||
+ }
|
||||
+ Flow {
|
||||
+ Layout.fillWidth: true
|
||||
+ spacing: 6
|
||||
+ Repeater {
|
||||
+ model: root.folderSuggestions(folderField.text)
|
||||
+ delegate: HudChip {
|
||||
+ required property var modelData
|
||||
+ hud: root
|
||||
+ text: root.tildePath(modelData)
|
||||
+ onClicked: {
|
||||
+ root.setNewFolder(modelData)
|
||||
+ folderField.text = root.tildePath(modelData)
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ Text { text: "WORKSPACE"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||||
+ Flow {
|
||||
+ Layout.fillWidth: true
|
||||
+ spacing: 6
|
||||
+ HudChip {
|
||||
+ hud: root
|
||||
+ text: "New workspace"
|
||||
+ selected: root.newWorkspace === ""
|
||||
+ onClicked: root.newWorkspace = ""
|
||||
+ }
|
||||
+ Repeater {
|
||||
+ model: root.spawnOptions ? root.spawnOptions.workspaces : []
|
||||
+ delegate: HudChip {
|
||||
+ required property var modelData
|
||||
+ hud: root
|
||||
+ text: String(modelData.label)
|
||||
+ selected: root.newWorkspace === String(modelData.id)
|
||||
+ onClicked: root.newWorkspace = String(modelData.id)
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ Text { text: "NAME · OPTIONAL"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||||
+ HudField {
|
||||
+ id: nameField
|
||||
+ hud: root
|
||||
+ Layout.fillWidth: true
|
||||
+ placeholder: "e.g. Fix login bug"
|
||||
+ onCancelled: root.newAgentOpen = false
|
||||
+ onSubmitted: promptField.focusInput()
|
||||
+ }
|
||||
+
|
||||
+ Text { text: "FIRST MESSAGE · OPTIONAL"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||||
+ HudField {
|
||||
+ id: promptField
|
||||
+ hud: root
|
||||
+ Layout.fillWidth: true
|
||||
+ multiline: true
|
||||
+ placeholder: "What should it do?"
|
||||
+ onCancelled: root.newAgentOpen = false
|
||||
+ }
|
||||
+
|
||||
+ Text {
|
||||
+ Layout.fillWidth: true
|
||||
+ visible: !!root.newAgentError
|
||||
+ text: root.newAgentError
|
||||
+ color: root.urgent
|
||||
+ wrapMode: Text.Wrap
|
||||
+ font.family: root.chromeFont
|
||||
+ font.pixelSize: 12
|
||||
+ }
|
||||
+
|
||||
+ RowLayout {
|
||||
+ Layout.fillWidth: true
|
||||
+ Layout.topMargin: 4
|
||||
+ spacing: 8
|
||||
+ Text {
|
||||
+ Layout.fillWidth: true
|
||||
+ text: "Opens as a HERDR tab."
|
||||
+ color: root.muted
|
||||
+ font.family: root.chromeFont
|
||||
+ font.pixelSize: 11
|
||||
+ elide: Text.ElideRight
|
||||
+ }
|
||||
+ HudChip { hud: root; text: "Cancel"; onClicked: root.newAgentOpen = false }
|
||||
+ HudChip {
|
||||
+ hud: root
|
||||
+ primary: true
|
||||
+ text: root.newAgentBusy ? "Starting…" : "Start agent"
|
||||
+ enabled: !root.newAgentBusy && !!root.newKind
|
||||
+ onClicked: root.startAgent(nameField.text, promptField.text)
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
@@ -1051,15 +1474,36 @@ Item {
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
- Text {
|
||||
+ RowLayout {
|
||||
Layout.fillWidth: true
|
||||
- text: root.demoMode ? "HERDR · PREVIEW" : root.connected
|
||||
- ? "HERDR · " + root.agents.length + " AGENTS" : "HERDR · OFFLINE"
|
||||
- color: root.muted
|
||||
- font.family: root.chromeFont
|
||||
- font.pixelSize: 11
|
||||
- font.bold: true
|
||||
- leftPadding: 5
|
||||
+ spacing: 6
|
||||
+ Text {
|
||||
+ Layout.fillWidth: true
|
||||
+ text: root.demoMode ? "HERDR · PREVIEW" : root.connected
|
||||
+ ? "HERDR · " + root.agents.length + " AGENTS" : "HERDR · OFFLINE"
|
||||
+ color: root.muted
|
||||
+ font.family: root.chromeFont
|
||||
+ font.pixelSize: 11
|
||||
+ font.bold: true
|
||||
+ leftPadding: 5
|
||||
+ elide: Text.ElideRight
|
||||
+ }
|
||||
+ HudChip {
|
||||
+ hud: root
|
||||
+ text: "+ New"
|
||||
+ enabled: root.connected && !root.demoMode
|
||||
+ selected: root.newAgentOpen
|
||||
+ onClicked: root.newAgentOpen ? root.newAgentOpen = false : root.openNewAgent()
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ HudChip {
|
||||
+ // Click for the next order, right-click for the previous one.
|
||||
+ hud: root
|
||||
+ Layout.leftMargin: 4
|
||||
+ text: "Sort: " + Roster.SORT_LABELS[root.sortMode] + " ⇅"
|
||||
+ onClicked: root.cycleSort(1)
|
||||
+ onRightClicked: root.cycleSort(-1)
|
||||
}
|
||||
|
||||
ListView {
|
||||
@@ -1093,12 +1537,16 @@ Item {
|
||||
z: 1
|
||||
}
|
||||
|
||||
+ readonly property string pane: String(modelData.pane_id || "")
|
||||
+ readonly property bool confirming: root.confirmClosePane === pane
|
||||
+
|
||||
MouseArea {
|
||||
id: agentMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
- onClicked: root.selectAgent(String(agentRow.modelData.pane_id || ""))
|
||||
+ onClicked: root.selectAgent(agentRow.pane)
|
||||
}
|
||||
+ HoverHandler { id: rowHover }
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
@@ -1123,7 +1571,7 @@ Item {
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
- text: String(agentRow.modelData.workspace_label || "Untitled space")
|
||||
+ text: root.agentName(agentRow.modelData)
|
||||
color: root.accent
|
||||
font.family: root.chromeFont
|
||||
font.pixelSize: 15
|
||||
@@ -1132,32 +1580,59 @@ Item {
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
- // Short line, so the context size isn't elided in a narrow roster.
|
||||
+ // Context size first, so a narrow roster elides the workspace instead.
|
||||
textFormat: Text.StyledText
|
||||
- text: root.escapeHtml(root.agentName(agentRow.modelData))
|
||||
- + (root.contextLabel(agentRow.modelData)
|
||||
- ? " · <font color=\"" + root.contextColor(agentRow.modelData) + "\"><b>"
|
||||
- + root.contextLabel(agentRow.modelData) + "</b></font>"
|
||||
+ text: (root.contextLabel(agentRow.modelData)
|
||||
+ ? "<font color=\"" + root.contextColor(agentRow.modelData) + "\"><b>"
|
||||
+ + root.contextLabel(agentRow.modelData) + "</b></font> · "
|
||||
: "")
|
||||
+ + root.escapeHtml(String(agentRow.modelData.workspace_label || "Untitled space"))
|
||||
+ + (root.agentName(agentRow.modelData) !== root.agentKind(agentRow.modelData)
|
||||
+ ? " · " + root.escapeHtml(root.agentKind(agentRow.modelData)) : "")
|
||||
color: root.muted
|
||||
font.family: root.chromeFont
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
+ visible: !agentRow.confirming
|
||||
Layout.fillWidth: true
|
||||
- text: (root.unread[String(agentRow.modelData.pane_id || "")] ? "Unseen update · " : "")
|
||||
- + root.statusLabel(agentRow.modelData)
|
||||
- color: root.unread[String(agentRow.modelData.pane_id || "")] ? root.success : root.muted
|
||||
+ text: root.closingPane === agentRow.pane ? "Closing…"
|
||||
+ : (root.unread[agentRow.pane] ? "Unseen update · " : "") + root.statusLabel(agentRow.modelData)
|
||||
+ color: root.unread[agentRow.pane] ? root.success : root.muted
|
||||
font.family: root.chromeFont
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
+ RowLayout {
|
||||
+ visible: agentRow.confirming
|
||||
+ spacing: 6
|
||||
+ HudChip { hud: root; text: "Close agent"; primary: true; danger: true; onClicked: root.closeAgent(agentRow.pane) }
|
||||
+ HudChip { hud: root; text: "Keep"; onClicked: root.confirmClosePane = "" }
|
||||
+ }
|
||||
}
|
||||
|
||||
+ Text {
|
||||
+ // Close, shown on hover; asks before closing.
|
||||
+ visible: (rowHover.hovered || agentRow.confirming) && !root.demoMode && root.closingPane !== agentRow.pane
|
||||
+ Layout.alignment: Qt.AlignTop
|
||||
+ text: "\u00d7"
|
||||
+ color: closeMouse.containsMouse || agentRow.confirming ? root.urgent : root.muted
|
||||
+ font.family: root.chromeFont
|
||||
+ font.pixelSize: 16
|
||||
+ font.bold: true
|
||||
+ MouseArea {
|
||||
+ id: closeMouse
|
||||
+ anchors.fill: parent
|
||||
+ anchors.margins: -4
|
||||
+ hoverEnabled: true
|
||||
+ cursorShape: Qt.PointingHandCursor
|
||||
+ onClicked: root.confirmClosePane = agentRow.confirming ? "" : agentRow.pane
|
||||
+ }
|
||||
+ }
|
||||
Text {
|
||||
// Matches the Alt+1-9 shortcut for this agent.
|
||||
- visible: agentRow.index < 9
|
||||
+ visible: agentRow.index < 9 && !rowHover.hovered && !agentRow.confirming
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.topMargin: 2
|
||||
text: String(agentRow.index + 1)
|
||||
diff --git i/README.md w/README.md
|
||||
index 685096b..37df3e5 100644
|
||||
--- i/README.md
|
||||
+++ w/README.md
|
||||
@@ -44,6 +44,14 @@ rm -rf ~/.config/herdr-hud-native
|
||||
- Approval-dialog keyboard input and TUI rendering
|
||||
- Omarchy and WoW designs
|
||||
- Agent switching, launcher, alerts, hidden-panel notifications
|
||||
+- Agent cards lead with the agent's name (Claude's session name, else the Herdr agent name)
|
||||
+- Sort the roster by Herdr order, status, recent activity, name, or workspace (click to cycle,
|
||||
+ right-click to go back). Orders other than Herdr's are worked out when the panel opens, the sort
|
||||
+ changes, or agents come or go, so a row never moves while you are looking at it
|
||||
+- **+ New** starts any installed agent Herdr supports in a new tab: pick the agent, folder, and
|
||||
+ workspace, with an optional name and first message. Also `omarchy-shell shell call indie.herdr-hud newAgent '{}'`
|
||||
+- Hover a card and click × to close its agent (asks first). A `claude attach` pane's background
|
||||
+ session is stopped too, keeping its conversation
|
||||
- Safe reconnect, disconnect, bounded read-only monitoring, and dependency errors
|
||||
|
||||
The plugin supports Omarchy only. It does not provide an HTML renderer, prompt bridge, legacy plugin migration, or independent chat history. Herdr owns terminal controller, resize, scroll, and alternate-screen behavior.
|
||||
diff --git i/Roster.js w/Roster.js
|
||||
index f88af7c..51ba497 100644
|
||||
--- i/Roster.js
|
||||
+++ w/Roster.js
|
||||
@@ -13,3 +13,53 @@ function sorted(agents, unread) {
|
||||
return a.priority - b.priority || a.index - b.index
|
||||
}).map(function(item) { return item.agent })
|
||||
}
|
||||
+
|
||||
+// The agent's own name: Claude's session name (its terminal title, or the
|
||||
+// claude_name token herdr-web reports), else the Herdr agent name, else its kind.
|
||||
+function displayName(agent) {
|
||||
+ if (!agent) return "Agent"
|
||||
+ var token = agent.tokens && agent.tokens.claude_name
|
||||
+ if (token) return String(token)
|
||||
+ var title = String(agent.terminal_title_stripped || "").trim()
|
||||
+ if (agent.agent === "claude" && title && !/^[^\s@]+@[^\s:]+:/.test(title) && title !== "Claude Code") return title
|
||||
+ if (agent.name) return String(agent.name)
|
||||
+ var type = String(agent.agent || "agent")
|
||||
+ return type.charAt(0).toUpperCase() + type.slice(1)
|
||||
+}
|
||||
+
|
||||
+var SORTS = ["herdr", "status", "recent", "name", "workspace"]
|
||||
+var SORT_LABELS = { herdr: "Herdr", status: "Status", recent: "Recent", name: "Name", workspace: "Workspace" }
|
||||
+var STATUS_RANK = { blocked: 0, working: 1, done: 2, idle: 3 }
|
||||
+
|
||||
+// Pane ids in display order. activity maps pane id -> last-activity time (ms).
|
||||
+// Ties keep Herdr's order.
|
||||
+function order(agents, mode, activity) {
|
||||
+ var rows = agents.map(function(agent, index) { return { agent: agent, index: index } })
|
||||
+ var name = function(row) { return displayName(row.agent).toLowerCase() }
|
||||
+ var compare = {
|
||||
+ status: function(a, b) {
|
||||
+ var ra = STATUS_RANK[a.agent.agent_status], rb = STATUS_RANK[b.agent.agent_status]
|
||||
+ return (ra === undefined ? 4 : ra) - (rb === undefined ? 4 : rb)
|
||||
+ },
|
||||
+ recent: function(a, b) {
|
||||
+ return (activity[b.agent.pane_id] || 0) - (activity[a.agent.pane_id] || 0)
|
||||
+ },
|
||||
+ name: function(a, b) { return name(a) < name(b) ? -1 : name(a) > name(b) ? 1 : 0 },
|
||||
+ workspace: function(a, b) {
|
||||
+ var wa = String(a.agent.workspace_label || "").toLowerCase(), wb = String(b.agent.workspace_label || "").toLowerCase()
|
||||
+ return wa < wb ? -1 : wa > wb ? 1 : 0
|
||||
+ },
|
||||
+ }[mode]
|
||||
+ if (compare) rows.sort(function(a, b) { return compare(a, b) || a.index - b.index })
|
||||
+ return rows.map(function(row) { return String(row.agent.pane_id || "") })
|
||||
+}
|
||||
+
|
||||
+// Agents in a saved order; agents the order doesn't know yet go last, in Herdr order.
|
||||
+function arrange(agents, paneOrder) {
|
||||
+ var position = {}
|
||||
+ paneOrder.forEach(function(pane, index) { position[pane] = index })
|
||||
+ return agents.map(function(agent, index) {
|
||||
+ var at = position[String(agent.pane_id || "")]
|
||||
+ return { agent: agent, key: at === undefined ? paneOrder.length + index : at }
|
||||
+ }).sort(function(a, b) { return a.key - b.key }).map(function(row) { return row.agent })
|
||||
+}
|
||||
diff --git i/bin/herdr-monitor w/bin/herdr-monitor
|
||||
index 1944808..aac81b3 100755
|
||||
--- i/bin/herdr-monitor
|
||||
+++ w/bin/herdr-monitor
|
||||
@@ -113,6 +113,7 @@ def roster() -> None:
|
||||
item["workspace_label"] = labels.get(workspace_id, workspace_id or "Untitled space")
|
||||
item["tab_label"] = tab_labels.get(str(row.get("tab_id", "")), str(row.get("tab_id") or ""))
|
||||
item["context_tokens"], item["context_limit"] = context_usage(row)
|
||||
+ item["active_at"] = last_active(row)
|
||||
merged.append(item)
|
||||
response({"ok": True, "agents": merged})
|
||||
|
||||
@@ -328,6 +329,18 @@ CONTEXT_READERS = {
|
||||
}
|
||||
|
||||
|
||||
+def last_active(row: dict) -> float:
|
||||
+ """When the agent last wrote to its session store (epoch ms); 0 when unknown."""
|
||||
+ session = (row.get("agent_session") or {}).get("value")
|
||||
+ if row.get("agent") != "claude" or not session:
|
||||
+ return 0
|
||||
+ try:
|
||||
+ transcript = claude_transcript(str(session))
|
||||
+ return transcript.stat().st_mtime * 1000 if transcript else 0
|
||||
+ except OSError:
|
||||
+ return 0
|
||||
+
|
||||
+
|
||||
def context_usage(row: dict) -> tuple[int, int]:
|
||||
"""(context tokens, context window) for an agent; zeros when unknown."""
|
||||
reader = CONTEXT_READERS.get(str(row.get("agent", "")))
|
||||
diff --git i/tests/test-alerts.mjs w/tests/test-alerts.mjs
|
||||
index 9a98741..988cf9c 100644
|
||||
--- i/tests/test-alerts.mjs
|
||||
+++ w/tests/test-alerts.mjs
|
||||
@@ -72,7 +72,8 @@ function unreadContext() {
|
||||
Alerts: alerts, demoMode: false, alertBaseline: false, agents: [], unread: {},
|
||||
lastSequence: {}, workingSince: {}, opened: false, selectedPane: '',
|
||||
activeAlert: null, activeAlertNeedsInput: false, connected: false, errorText: '',
|
||||
- dataRevision: 0,
|
||||
+ dataRevision: 0, activity: {}, pendingPane: '', confirmClosePane: '', resort() {},
|
||||
+ get sortedAgents() { return context.agents; },
|
||||
agentForPane(pane) {
|
||||
for (const item of context.agents) if (String(item.pane_id || '') === pane) return item;
|
||||
return null;
|
||||
diff --git i/tests/test-roster.mjs w/tests/test-roster.mjs
|
||||
index 9ea8eef..b92cdb2 100644
|
||||
--- i/tests/test-roster.mjs
|
||||
+++ w/tests/test-roster.mjs
|
||||
@@ -22,3 +22,30 @@ test('attention first, then working, then read; stable within groups', () => {
|
||||
test('reading a reply moves it down; blocked agents still need attention', () => {
|
||||
assert.deepEqual(order({}), ['blocked', 'busy', 'read', 'unread', 'idle']);
|
||||
});
|
||||
+
|
||||
+const crew = [
|
||||
+ { pane_id: 'a', agent: 'codex', name: 'zeta', agent_status: 'idle', workspace_label: 'web' },
|
||||
+ { pane_id: 'b', agent: 'claude', terminal_title_stripped: 'Alpha task', agent_status: 'working', workspace_label: 'api' },
|
||||
+ { pane_id: 'c', agent: 'claude', terminal_title_stripped: 'me@host:~/x', tokens: { claude_name: 'Mid' }, agent_status: 'blocked', workspace_label: 'web' },
|
||||
+ { pane_id: 'd', agent: 'hermes', agent_status: 'done', workspace_label: 'api' },
|
||||
+];
|
||||
+const by = (mode, activity = {}) => Array.from(roster.order(crew, mode, activity));
|
||||
+
|
||||
+test('display names prefer the Claude session name, then the Herdr name, then the kind', () => {
|
||||
+ assert.deepEqual(crew.map(a => roster.displayName(a)), ['zeta', 'Alpha task', 'Mid', 'Hermes']);
|
||||
+ assert.equal(roster.displayName({ agent: 'claude', terminal_title_stripped: 'me@host:~/w' }), 'Claude');
|
||||
+});
|
||||
+
|
||||
+test('sort modes; ties keep Herdr order', () => {
|
||||
+ assert.deepEqual(by('herdr'), ['a', 'b', 'c', 'd']);
|
||||
+ assert.deepEqual(by('status'), ['c', 'b', 'd', 'a']);
|
||||
+ assert.deepEqual(by('name'), ['b', 'd', 'c', 'a']);
|
||||
+ assert.deepEqual(by('workspace'), ['b', 'd', 'a', 'c']);
|
||||
+ assert.deepEqual(by('recent', { d: 30, a: 20, c: 10 }), ['d', 'a', 'c', 'b']);
|
||||
+});
|
||||
+
|
||||
+test('arrange keeps a saved order and appends new agents', () => {
|
||||
+ const ids = rows => Array.from(rows, a => a.pane_id);
|
||||
+ assert.deepEqual(ids(roster.arrange(crew, ['d', 'b'])), ['d', 'b', 'a', 'c']);
|
||||
+ assert.deepEqual(ids(roster.arrange(crew.slice(0, 2), ['d', 'b', 'a'])), ['b', 'a']);
|
||||
+});
|
||||
diff --git i/tests/test_native.py w/tests/test_native.py
|
||||
index baa9589..bd36bab 100644
|
||||
--- i/tests/test_native.py
|
||||
+++ w/tests/test_native.py
|
||||
@@ -260,9 +260,13 @@ class NativeTests(unittest.TestCase):
|
||||
self.assertNotIn("QMLTermWidget", source)
|
||||
|
||||
def test_roster_keeps_herdr_order(self):
|
||||
- # Re-sorting by attention moved agents under the cursor on selection.
|
||||
+ # Re-sorting live by attention moved agents under the cursor on
|
||||
+ # selection. Herdr's order is the default, and other sorts use a saved
|
||||
+ # order that only changes on open, a sort change, or agents coming or going.
|
||||
source = (NATIVE / "HerdrHud.qml").read_text()
|
||||
- self.assertIn("readonly property var sortedAgents: agents\n", source)
|
||||
+ self.assertIn('property string sortMode: "herdr"\n', source)
|
||||
+ self.assertIn('sortedAgents: sortMode === "herdr" ? agents : Roster.arrange(agents, rosterOrder)\n', source)
|
||||
+ self.assertIn("if (!opened || paneSetChanged) resort()", source)
|
||||
self.assertNotIn("Roster.sorted(", source)
|
||||
|
||||
def test_release_structure(self):
|
||||
@@ -0,0 +1,48 @@
|
||||
diff --git i/Cat.qml w/Cat.qml
|
||||
index 58ed7f6..f1bbe9b 100644
|
||||
--- i/Cat.qml
|
||||
+++ w/Cat.qml
|
||||
@@ -30,13 +30,18 @@ Item {
|
||||
property string omarchyPath: ""
|
||||
|
||||
readonly property string pluginId: manifest && manifest.id ? String(manifest.id) : "io.github.tallsam.navbar-cat"
|
||||
+ // The shell strips `__sourceDir` from third-party manifests, so fall back to
|
||||
+ // this file's own directory.
|
||||
+ readonly property string pluginDir: manifest && manifest.__sourceDir
|
||||
+ ? String(manifest.__sourceDir)
|
||||
+ : String(Qt.resolvedUrl(".")).replace(/^file:\/\//, "").replace(/\/$/, "")
|
||||
readonly property string assetDir: {
|
||||
- var dir = manifest && manifest.__sourceDir ? String(manifest.__sourceDir) : ""
|
||||
+ var dir = pluginDir
|
||||
if (dir === "") return ""
|
||||
return (dir.indexOf("file://") === 0 ? dir : "file://" + dir) + "/assets"
|
||||
}
|
||||
readonly property string helper: {
|
||||
- var dir = manifest && manifest.__sourceDir ? String(manifest.__sourceDir) : ""
|
||||
+ var dir = pluginDir
|
||||
if (dir === "") return ""
|
||||
return dir.replace(/^file:\/\//, "") + "/bin/navbar-cat-cursor"
|
||||
}
|
||||
@@ -52,8 +57,21 @@ Item {
|
||||
|
||||
// Settings live inline on this plugin's entry in shell.json's `plugins[]`,
|
||||
// per the shell's storage rules.
|
||||
+ // Third-party plugins get a scoped `shell` without `shellConfig`, so read
|
||||
+ // shell.json directly when the shell does not hand it over.
|
||||
+ FileView {
|
||||
+ id: shellJson
|
||||
+ path: Quickshell.env("HOME") + "/.config/omarchy/shell.json"
|
||||
+ watchChanges: true
|
||||
+ onFileChanged: reload()
|
||||
+ }
|
||||
+ readonly property var fileConfig: {
|
||||
+ try { return JSON.parse(shellJson.text()) } catch (e) { return null }
|
||||
+ }
|
||||
+
|
||||
readonly property var config: {
|
||||
- var settings = shell && shell.shellConfig ? shell.shellConfig.plugins : null
|
||||
+ var source = shell && shell.shellConfig ? shell.shellConfig : root.fileConfig
|
||||
+ var settings = source ? source.plugins : null
|
||||
if (Array.isArray(settings)) {
|
||||
for (var i = 0; i < settings.length; i++) {
|
||||
if (settings[i] && String(settings[i].id || "") === root.pluginId) return settings[i]
|
||||
@@ -0,0 +1,12 @@
|
||||
# Omarchy shell plugins. Fields: id<TAB>repo<TAB>pinned-sha
|
||||
# install.sh clones each into ~/.config/omarchy/plugins/<id> and checks out the sha.
|
||||
akshar.radio-atlas https://github.com/AksharP5/omarchy-radio-atlas.git ffa7186b53e00be624b56503716c479bb97f5ff4
|
||||
crmne.hyprmoncfg https://github.com/crmne/omarchy-hyprmoncfg.git 193e5611f934eec42460ef75f3f46c2a8bd0252a
|
||||
ilyazar.btop https://github.com/omarchy-QOL/omarchy-btop-activity.git 1e896f6f37bf880dee3e55e36ff04f967745c527
|
||||
indie.herdr-hud https://github.com/kt3v/omarchy-herdr-hud-native.git 75bef0f8012304a4edfec90c690b9f45fe65a04e
|
||||
io.github.tallsam.navbar-cat https://github.com/tallsam/omarchy-navbar-cat.git 6ddd61a6744bab19b04d131ff4f2e087a504ce20
|
||||
io.github.thisisgm.omapods https://github.com/thisisgm/omarchy-pods fff7fec600a5b9a61cdb40e93eccbcceb4b8f824
|
||||
kb2uka.port-doctor https://github.com/Kb2uka/omarchy-port-doctor.git baa13451bfc27d59e8c831c217852279a20901aa
|
||||
kokd.remote-desktop https://github.com/kostadinkadiev/omarchy-remote-desktop.git 7913b89521f88fac40620f495d1e07835bbb55b4
|
||||
omaplug https://github.com/fross100/omaplug.git 9da73fc13fc2027be5b44915b4ab89440beb74ab
|
||||
vstoms.netbird https://github.com/vstoms/omarchy-netbird.git d530c76b13324b26dafd79934529b4395e18a11c
|
||||
@@ -0,0 +1 @@
|
||||
options hid_apple fnmode=2
|
||||
@@ -0,0 +1,9 @@
|
||||
# Two NICs (Wi-Fi wlo1, dock ethernet enp0s13f0u3u4u3) share 192.168.1.0/24.
|
||||
# With the defaults (arp_ignore=0/arp_announce=0) the host answers ARP for any
|
||||
# local address on any interface, so the subnet's mapping collapses onto
|
||||
# whichever NIC replied last -- traffic aimed at the Wi-Fi IP lands on the dock,
|
||||
# and the Wi-Fi path silently never gets exercised.
|
||||
# 1 = only answer ARP for addresses configured on the receiving interface.
|
||||
# 2 = always use the best local address for the target subnet when announcing.
|
||||
net.ipv4.conf.all.arp_ignore = 1
|
||||
net.ipv4.conf.all.arp_announce = 2
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=lan-mouse input sharing daemon (omarchy-handoff)
|
||||
PartOf=graphical-session.target
|
||||
After=graphical-session.target xdg-desktop-portal-hyprland.service
|
||||
Requisite=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# LAN_MOUSE_LOG_LEVEL (NOT RUST_LOG -- lan-mouse ignores that) is what makes auth rejections visible at all; lan-mouse logs the
|
||||
# offending fingerprint only at debug level. Drop to "info" once paired.
|
||||
Environment=LAN_MOUSE_LOG_LEVEL=debug
|
||||
ExecStart=/usr/bin/lan-mouse daemon
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
# Output goes to the journal on purpose. The previous autostart.lua path sent
|
||||
# both streams to /dev/null, which hid every pairing failure for hours.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Voxtype push-to-talk voice-to-text daemon
|
||||
Documentation=https://voxtype.io
|
||||
PartOf=graphical-session.target
|
||||
After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/voxtype daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Ensure we have access to the display
|
||||
Environment=XDG_RUNTIME_DIR=%t
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -0,0 +1,5 @@
|
||||
Backgrounds from Wikimedia Commons (cropped to 3840x2160):
|
||||
1-giallo-studio.jpg "2009 Ferrari 458 Italia Museo Nazionale dell'Automobile Torino 01" by Rahil Rupawala — CC BY 2.0
|
||||
2-london-nights.jpg "Ferarri F458 Version 2 (8138264334)" by Ben — CC BY-SA 2.0
|
||||
3-light-trails.jpg "Ferarri White 458 (8157299104)" by Ben — CC BY-SA 2.0
|
||||
4-at-speed.jpg "Ferrari 458 (8925562883)" by emperornie — CC BY-SA 2.0
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,35 @@
|
||||
# Ferrari 458 Italia — Nero Daytona body, Crema leather interior, Rosso Corsa
|
||||
# accents, Giallo Modena shield, Tricolore green, Azzurro California and Blu Tour de France.
|
||||
mode = "dark"
|
||||
|
||||
accent = "#d40000"
|
||||
selection = "#3a2a28"
|
||||
muted = "#665b57"
|
||||
|
||||
background = "#141110"
|
||||
dark_background = "#0d0b0a"
|
||||
darker_background = "#070606"
|
||||
lighter_background = "#221c1a"
|
||||
|
||||
foreground = "#ece2d0"
|
||||
dark_foreground = "#857a6c"
|
||||
light_foreground = "#d6cab6"
|
||||
bright_foreground = "#f8f1e4"
|
||||
|
||||
red = "#d40000"
|
||||
yellow = "#fcd116"
|
||||
orange = "#ff5a1f"
|
||||
green = "#009246"
|
||||
cyan = "#3aa0c8"
|
||||
blue = "#5b7fc7"
|
||||
magenta = "#c2185b"
|
||||
brown = "#a0522d"
|
||||
|
||||
bright_red = "#ff2800"
|
||||
bright_yellow = "#ffe45c"
|
||||
bright_green = "#3cc47c"
|
||||
bright_cyan = "#6fcbea"
|
||||
bright_blue = "#8fa9e0"
|
||||
bright_magenta = "#e8578f"
|
||||
|
||||
hyprland_active_border = "rgba(d40000ee) rgba(fcd116ee) 45deg"
|
||||
@@ -0,0 +1 @@
|
||||
Yaru-red-dark
|
||||
@@ -0,0 +1,6 @@
|
||||
Backgrounds from Wikimedia Commons:
|
||||
1-rainy-evening.jpg "Porsche 911 on a rainy evening (Unsplash)" — CC0
|
||||
2-gulf-917.jpg "Gulf Porsche 917" — CC BY 2.0
|
||||
3-night.jpg "Porsche Porsche at night (8169422529)" — CC BY-SA 2.0
|
||||
4-carrera-rs.jpg "Porsche 911 Carrera RS, Bj. 1972-73 (2016-07-02 01 Sp)" — CC BY-SA 2.0 de
|
||||
0-718-cayman-s.jpg "Porsche 718 Cayman S back IMG 0704" by Alexander Migl — CC BY-SA 4.0 (floor extended to 16:9)
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,35 @@
|
||||
# Porsche — Jet Black Metallic body, Chalk interior, Guards Red accents,
|
||||
# with Racing Yellow, Lava Orange, Python Green, Miami Blue and Gulf livery blue.
|
||||
mode = "dark"
|
||||
|
||||
accent = "#d5001c"
|
||||
selection = "#33333a"
|
||||
muted = "#5c5c64"
|
||||
|
||||
background = "#121214"
|
||||
dark_background = "#0c0c0e"
|
||||
darker_background = "#070708"
|
||||
lighter_background = "#1f1f23"
|
||||
|
||||
foreground = "#e6e1d8"
|
||||
dark_foreground = "#7a766f"
|
||||
light_foreground = "#cfc9bd"
|
||||
bright_foreground = "#f5f1ea"
|
||||
|
||||
red = "#d5001c"
|
||||
yellow = "#f7c600"
|
||||
orange = "#ff6a13"
|
||||
green = "#3fae49"
|
||||
cyan = "#00a3c7"
|
||||
blue = "#7fb2dd"
|
||||
magenta = "#c7346a"
|
||||
brown = "#8b5a2b"
|
||||
|
||||
bright_red = "#ff3343"
|
||||
bright_yellow = "#ffdd4d"
|
||||
bright_green = "#6fd36f"
|
||||
bright_cyan = "#4fd2ef"
|
||||
bright_blue = "#a9cdee"
|
||||
bright_magenta = "#e9659a"
|
||||
|
||||
hyprland_active_border = "rgba(d5001cee) rgba(ff6a13ee) 45deg"
|
||||
@@ -0,0 +1 @@
|
||||
Yaru-red-dark
|
||||
@@ -0,0 +1,14 @@
|
||||
# The Simpsons theme — real wallpapers pulled from the internet (wallhaven.cc),
|
||||
# palette derived from them.
|
||||
|
||||
Backgrounds:
|
||||
1-springfield-wasteland.jpg Homer & Bart, desert/RV scene (wallhaven 43pr6n,
|
||||
source: thomasjakeross.deviantart.com, SFW)
|
||||
2-bart-sunset.jpg Bart mid-air skateboard trick, purple-pink sunset
|
||||
sky over Springfield (wallhaven 4vm36m)
|
||||
3-duff-wall.jpg Homer surrounded by a wall of Duff, donuts and
|
||||
Krusty burgers (wallhaven o4d1m4)
|
||||
|
||||
All images fetched via the wallhaven.cc public API, SFW-rated, kept at native
|
||||
resolution (1920x1200 / 1920x1080). Preview is the wasteland shot plus a
|
||||
palette swatch strip.
|
||||
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,93 @@
|
||||
# Main background
|
||||
theme[main_bg]="#1c1712"
|
||||
|
||||
# Main text color
|
||||
theme[main_fg]="#efe6d4"
|
||||
|
||||
# Title color for boxes
|
||||
theme[title]="#ffd12e"
|
||||
|
||||
# Highlight color for keyboard shortcuts
|
||||
theme[hi_fg]="#56b6c2"
|
||||
|
||||
# Background color of selected item in processes box
|
||||
theme[selected_bg]="#3a3148"
|
||||
|
||||
# Foreground color of selected item in processes box
|
||||
theme[selected_fg]="#ffd12e"
|
||||
|
||||
# Color of inactive/disabled text
|
||||
theme[inactive_fg]="#6b6478"
|
||||
|
||||
# Color of text appearing on top of graphs
|
||||
theme[graph_text]="#efe6d4"
|
||||
|
||||
# Background color of the percentage meters
|
||||
theme[meter_bg]="#2a241c"
|
||||
|
||||
# Misc colors for processes box
|
||||
theme[proc_misc]="#efe6d4"
|
||||
|
||||
# CPU, Memory, Network, Proc box outline colors
|
||||
theme[cpu_box]="#6b6478"
|
||||
theme[mem_box]="#6b6478"
|
||||
theme[net_box]="#6b6478"
|
||||
theme[proc_box]="#6b6478"
|
||||
|
||||
# Box divider line and small boxes line color
|
||||
theme[div_line]="#96705a"
|
||||
|
||||
# Temperature graph color (Green -> Yellow -> Red)
|
||||
theme[temp_start]="#6db567"
|
||||
theme[temp_mid]="#ffd12e"
|
||||
theme[temp_end]="#e2584a"
|
||||
|
||||
# CPU graph colors (Blue -> Cyan -> Magenta)
|
||||
theme[cpu_start]="#5c8fd6"
|
||||
theme[cpu_mid]="#56b6c2"
|
||||
theme[cpu_end]="#c76ba0"
|
||||
|
||||
# Mem/Disk free meter (Reversed "Available" colors: Cyan -> Blue -> Purple)
|
||||
theme[free_start]="#56b6c2"
|
||||
theme[free_mid]="#5c8fd6"
|
||||
theme[free_end]="#c76ba0"
|
||||
|
||||
# Mem/Disk cached meter
|
||||
theme[cached_start]="#5c8fd6"
|
||||
theme[cached_mid]="#56b6c2"
|
||||
theme[cached_end]="#efe6d4"
|
||||
|
||||
# Mem/Disk available meter (Purple -> Blue -> Cyan)
|
||||
theme[available_start]="#c76ba0"
|
||||
theme[available_mid]="#5c8fd6"
|
||||
theme[available_end]="#56b6c2"
|
||||
|
||||
# Mem/Disk used meter
|
||||
theme[used_start]="#e2584a"
|
||||
theme[used_mid]="#96705a"
|
||||
theme[used_end]="#6b6478"
|
||||
|
||||
# Download graph colors
|
||||
theme[download_start]="#6db567"
|
||||
theme[download_mid]="#56b6c2"
|
||||
theme[download_end]="#efe6d4"
|
||||
|
||||
# Upload graph colors
|
||||
theme[upload_start]="#5c8fd6"
|
||||
theme[upload_mid]="#56b6c2"
|
||||
theme[upload_end]="#efe6d4"
|
||||
|
||||
# Process box color gradient
|
||||
theme[process_start]="#56b6c2"
|
||||
theme[process_mid]="#5c8fd6"
|
||||
theme[process_end]="#e2584a"
|
||||
|
||||
# Graph gradient colors
|
||||
theme[gradient_color_0]="#1c1712"
|
||||
theme[gradient_color_1]="#6b6478"
|
||||
theme[gradient_color_2]="#96705a"
|
||||
theme[gradient_color_3]="#e2584a"
|
||||
theme[gradient_color_4]="#6db567"
|
||||
theme[gradient_color_5]="#5c8fd6"
|
||||
theme[gradient_color_6]="#56b6c2"
|
||||
theme[gradient_color_7]="#ffe45e"
|
||||
@@ -0,0 +1 @@
|
||||
28,23,18
|
||||
@@ -0,0 +1,37 @@
|
||||
# The Simpsons — Springfield sky. Signature cartoon yellow over desert sand
|
||||
# and blue sky (breaking-bad frame), Bart's sunset sky, and the loud
|
||||
# Duff-pink of a junk-food wall.
|
||||
mode = "dark"
|
||||
|
||||
accent = "#ffd12e"
|
||||
selection = "#3a3148"
|
||||
muted = "#6b6478"
|
||||
|
||||
background = "#1c1712"
|
||||
dark_background = "#14100c"
|
||||
darker_background = "#0c0a07"
|
||||
lighter_background = "#2a241c"
|
||||
|
||||
foreground = "#efe6d4"
|
||||
dark_foreground = "#8a8294"
|
||||
light_foreground = "#d8cfbd"
|
||||
bright_foreground = "#f8f2e2"
|
||||
|
||||
red = "#e2584a"
|
||||
yellow = "#ffd12e"
|
||||
orange = "#f2a04e"
|
||||
green = "#6db567"
|
||||
cyan = "#56b6c2"
|
||||
blue = "#5c8fd6"
|
||||
magenta = "#c76ba0"
|
||||
brown = "#96705a"
|
||||
|
||||
bright_red = "#ff7a6b"
|
||||
bright_yellow = "#ffe45e"
|
||||
bright_green = "#8fd48a"
|
||||
bright_cyan = "#7cd6e0"
|
||||
bright_blue = "#7fabef"
|
||||
bright_magenta = "#e291c0"
|
||||
|
||||
hyprland_active_border = "rgba(ffd12eee) rgba(56b6c2ee) 45deg"
|
||||
hyprland_inactive_border = "rgba(3a3148aa)"
|
||||
@@ -0,0 +1,17 @@
|
||||
local active_border_color = { colors = { "rgba(ffd12eee)", "rgba(56b6c2ee)" }, angle = 45 }
|
||||
local inactive_border_color = "rgba(3a3148aa)"
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
col = {
|
||||
active_border = active_border_color,
|
||||
inactive_border = inactive_border_color,
|
||||
},
|
||||
},
|
||||
group = {
|
||||
col = {
|
||||
border_active = active_border_color,
|
||||
border_inactive = inactive_border_color,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
Yaru-yellow-dark
|
||||
@@ -0,0 +1 @@
|
||||
ffd12e
|
||||
@@ -0,0 +1,13 @@
|
||||
return {
|
||||
{
|
||||
"catppuccin/nvim",
|
||||
name = "catppuccin",
|
||||
priority = 1000,
|
||||
},
|
||||
{
|
||||
"LazyVim/LazyVim",
|
||||
opts = {
|
||||
colorscheme = "catppuccin-mocha",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 450 KiB |
@@ -0,0 +1,6 @@
|
||||
text = "#efe6d4"
|
||||
placeholder = "#8a8294"
|
||||
text-error = "#efe6d4"
|
||||
border = "#efe6d4"
|
||||
border-active = "#ffd12e"
|
||||
border-error = "#e2584a"
|
||||