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"
|
||||||