Initial import: chezmoi source for Omarchy (Hyprland)

Imported from prdlk/dotfiles master (efdd827), which supersedes the omarchy
branch (3031416). Drops the Umbriel, Noctalia, niri, CachyOS, macOS, and Blume
docs-site layers; docs/ is plain Markdown.
This commit is contained in:
Prad Nukala committed 2026-11-02 09:14:11 -05:00
commit b18729f4a0
163 files changed
+12685

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Choose and launch an AI CLI via gum.
choice=$(gum choose --header 'AI CLI' claude opencode codex hermes fractal) || exit 0
[ -n "$choice" ] || exit 0
case "$choice" in
claude) exec claude --dangerously-skip-permissions ;;
# labels match the binary names, so just run it;
# add a dedicated case here if another tool needs flags
*) exec "$choice" ;;
esac
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""atuin-pick: atuin history picker for a kitty split (ctrl+shift+p).
atuin search -i draws its TUI on the tty and prints the selected command
to stdout (Esc prints nothing). Filter to history from the current
directory. Hand the selection to an interactive fish via --init-command:
it runs inside that shell (aliases/abbr resolve, cd persists) with its
output in the split, and the prompt drops down below it.
"""
import os
import shlex
import subprocess
# kitty's launch env has no ATUIN_SESSION (atuin's shell init never ran), so
# seed it here. atuin itself comes from the package manager, on PATH.
atuin = "atuin"
if "ATUIN_SESSION" not in os.environ:
os.environ["ATUIN_SESSION"] = subprocess.run(
[atuin, "uuid"], stdout=subprocess.PIPE, text=True
).stdout.strip()
sel = subprocess.run(
[atuin, "search", "-i", "--filter-mode", "directory"],
stdout=subprocess.PIPE,
text=True,
).stdout.strip()
argv = ["fish"]
if sel:
argv += ["-C", f"echo {shlex.quote('\u276f ' + sel)}; {sel}"]
os.execvp("fish", argv)
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# brave-new-tab URL
# Open URL in a new tab of the running Brave Origin (its last active window),
# or start Brave Origin with it. Detached, so the caller never blocks.
# Without a scheme, localhost, *.localhost, *.local, and 127.x get http://;
# anything else gets https://.
set -eu
[ $# -eq 1 ] || { echo "usage: brave-new-tab URL" >&2; exit 2; }
url=$1
case $url in
*://* | about:*) ;;
*)
host=${url%%[/?#]*}
host=$(printf '%s' "${host%:*}" | tr '[:upper:]' '[:lower:]')
case $host in
localhost | *.localhost | *.local | 127.*) url="http://$url" ;;
*) url="https://$url" ;;
esac
;;
esac
exec setsid -f brave-origin --new-tab "$url" >/dev/null 2>&1
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Code2Prompt with fuzzy-search template.
tpl=$(fd --type f --extension hbs . "$HOME/.config/code2prompt" | fzf) || exit 0
[ -n "$tpl" ] || exit 0
exec code2prompt --template "$tpl"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# Fuzzy-pick a desktop app ID and copy to clipboard.
existing_dirs=""
for d in /usr/share/applications \
/usr/local/share/applications \
"$HOME/.local/share/applications" \
/var/lib/flatpak/exports/share/applications \
"$HOME/.local/share/flatpak/exports/share/applications" \
/var/lib/snapd/desktop/applications; do
[ -d "$d" ] && existing_dirs="$existing_dirs $d"
done
if [ -z "$existing_dirs" ]; then
echo "No application directories found." >&2
exit 1
fi
# word splitting of $existing_dirs is intended
# shellcheck disable=SC2086
chosen=$(
fd --type f --extension desktop . $existing_dirs \
| while IFS= read -r f; do
app_id=$(basename "$f" .desktop)
name=$(sed -n 's/^Name=//p' "$f" | head -n1)
[ -z "$name" ] && name=$app_id
printf '%s\t%s\n' "$name" "$app_id"
done \
| sort -u -t "$(printf '\t')" -k1 \
| fzf \
--delimiter='\t' \
--with-nth=1,2 \
--prompt="App ID › " \
--height=50% \
--layout=reverse \
--border=rounded \
--info=inline
)
[ -n "$chosen" ] || exit 0
app_id=$(printf '%s\n' "$chosen" | cut -f2)
printf '%s' "$app_id" | wl-copy
echo "Copied: $app_id"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Copy all AGENTS.md files to a target directory while preserving structure.
out_dir=$1
if [ -z "$out_dir" ]; then
echo "Error: Output directory required." >&2
echo "Usage: extract_agents <absolute_or_relative_output_path>" >&2
exit 1
fi
mkdir -p "$out_dir"
# Find and copy preserving relative paths
fd -t f -g "AGENTS.md" -x cp --parents "{}" "$out_dir"
echo "Extraction complete. Check $out_dir."
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# Fuzzy find and execute a Fabric pattern.
json_file=$HOME/.config/fabric/pattern_explainations.json
if [ ! -f "$json_file" ]; then
printf '\033[31mError: %s not found.\033[0m\n' "$json_file" >&2
exit 1
fi
# fabric is aliased to fabric-ai in the shells; resolve the real binary here
if command -v fabric-ai >/dev/null 2>&1; then
fabric_bin=fabric-ai
else
fabric_bin=fabric
fi
# 1. Safely capture piped input (if any) to a temp file — preserves all
# formatting and newlines, avoids variable escaping issues
temp_input=$(mktemp)
trap 'rm -f "$temp_input"' EXIT INT TERM
if [ ! -t 0 ]; then
cat > "$temp_input"
fi
# 2. Parse JSON to TSV -> fzf (name in list, description in preview)
selection=$(jq -r '.[] | "\(.name)\t\(.description)"' "$json_file" \
| fzf --delimiter '\t' \
--with-nth 1 \
--preview 'echo {2}' \
--preview-window 'right:60%:wrap' \
--prompt 'Pattern > ' \
--info inline \
--bind 'ctrl-/:toggle-preview')
# 3. Execute with or without the piped data
if [ -n "$selection" ]; then
pattern=$(printf '%s\n' "$selection" | cut -f1)
if [ -s "$temp_input" ]; then
"$fabric_bin" --pattern "$pattern" < "$temp_input"
else
"$fabric_bin" --pattern "$pattern"
fi
fi
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Discard all git changes with gum confirmation.
if gum confirm "Discard all uncommitted changes? This is irreversible."; then
git reset --hard HEAD
git clean -fd
echo "Changes wiped."
else
echo "Operation aborted."
fi
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# gh-sonr-org-migrate
# Migrates all repos from orgs matching "sonr" into sonr-io,
# prompting via gum for renames on collision, then deletes emptied orgs.
#
# Requires: gh (authenticated as org owner on all orgs), gum, jq
TARGET_ORG=sonr-io
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
cyan() { printf '\033[36m%s\033[0m\n' "$*"; }
bcyan() { printf '\033[1;36m%s\033[0m\n' "$*"; }
bgreen() { printf '\033[1;32m%s\033[0m\n' "$*"; }
# ── Preflight ────────────────────────────────────────────────────────────────
for bin in gh gum jq; do
if ! command -v "$bin" >/dev/null 2>&1; then
red "missing dependency: $bin"
exit 1
fi
done
if ! gh auth status >/dev/null 2>&1; then
red "gh is not authenticated"
exit 1
fi
# ── Discover source orgs ─────────────────────────────────────────────────────
# All orgs the user belongs to whose login contains "sonr", excluding target.
mapfile -t source_orgs < <(gh api user/orgs --paginate --jq '.[].login' \
| grep 'sonr' | grep -vx "$TARGET_ORG")
if [ "${#source_orgs[@]}" -eq 0 ]; then
echo "No source orgs found. Nothing to do."
exit 0
fi
cyan "Source orgs:"
printf ' %s\n' "${source_orgs[@]}"
cyan "Target org:"
echo " $TARGET_ORG"
echo
# Let the user confirm / trim the list interactively
mapfile -t selected_orgs < <(printf '%s\n' "${source_orgs[@]}" | gum choose --no-limit \
--header "Select orgs to migrate into $TARGET_ORG (space to toggle, enter to confirm)")
if [ "${#selected_orgs[@]}" -eq 0 ]; then
echo "Nothing selected. Exiting."
exit 0
fi
# ── Helpers ──────────────────────────────────────────────────────────────────
repo_exists_in_target() {
gh api "repos/$TARGET_ORG/$1" --silent >/dev/null 2>&1
}
transfer_repo() {
# $1 = source org, $2 = repo name, $3 = (optional) new name
local src=$1 repo=$2 new_name=${3:-}
local args=(-f "new_owner=$TARGET_ORG")
if [ -n "$new_name" ]; then
args+=(-f "new_name=$new_name")
fi
gh api -X POST "repos/$src/$repo/transfer" "${args[@]}" --silent
}
# ── Migration ────────────────────────────────────────────────────────────────
failed_transfers=()
for org in "${selected_orgs[@]}"; do
echo
bcyan "── $org ──"
mapfile -t repos < <(gh api "orgs/$org/repos" --paginate --jq '.[].name')
if [ "${#repos[@]}" -eq 0 ]; then
echo " (no repos)"
continue
fi
for repo in "${repos[@]}"; do
new_name=""
if repo_exists_in_target "$repo"; then
yellow " ⚠ $repo already exists in $TARGET_ORG"
while true; do
new_name=$(gum input \
--header "New name for $org/$repo (exists in $TARGET_ORG)" \
--placeholder "$org-$repo" \
--value "$org-$repo")
if [ -z "$new_name" ]; then
echo " skipped $org/$repo"
new_name=__SKIP__
break
fi
if repo_exists_in_target "$new_name"; then
yellow " $new_name also exists — pick another"
else
break
fi
done
[ "$new_name" = __SKIP__ ] && continue
fi
label="$org/$repo → $TARGET_ORG/${new_name:-$repo}"
if transfer_repo "$org" "$repo" "$new_name"; then
green " ✓ $label"
else
red " ✗ failed: $label"
failed_transfers+=("$org/$repo")
fi
done
done
if [ "${#failed_transfers[@]}" -gt 0 ]; then
echo
red "Failed transfers (orgs containing these will NOT be deleted):"
printf ' %s\n' "${failed_transfers[@]}"
fi
# ── Wait for transfers to settle ─────────────────────────────────────────────
# GitHub repo transfers are asynchronous; give them time to drain.
echo
gum spin --title "Waiting 30s for transfers to settle..." -- sleep 30
# ── Delete emptied orgs ──────────────────────────────────────────────────────
echo
for org in "${selected_orgs[@]}"; do
# Skip orgs with failed transfers
org_had_failure=0
for f in ${failed_transfers[@]+"${failed_transfers[@]}"}; do
case "$f" in
"$org"/*) org_had_failure=1; break ;;
esac
done
if [ "$org_had_failure" -eq 1 ]; then
yellow "skipping delete: $org (had failures)"
continue
fi
remaining=$(gh api "orgs/$org/repos" --paginate --jq '. | length' | jq -s 'add // 0')
if [ "$remaining" -gt 0 ]; then
yellow "skipping delete: $org still has $remaining repo(s) (transfer pending?)"
continue
fi
if gum confirm "Delete empty org $org?"; then
if gh api -X DELETE "orgs/$org" --silent; then
green "✓ deleted $org"
else
red "✗ could not delete $org (API delete requires org owner; may need manual deletion in Settings)"
fi
else
echo "kept $org"
fi
done
echo
bgreen "Done."
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
# ghq get + resolve the on-disk clone path; prints the repo directory.
# Shell wrappers (fish/bash/zsh `ghg` functions) cd into the printed path —
# a child process can't change the caller's cwd.
#
# Then sets up a prad.codes mirror when the repo has none (best effort; the
# clone never fails over it):
# github.com/<owner>/<name> you admin → pull mirror into a prad.codes org
# (picked with gum, guessed from the language; esc skips), every 8h
# prad.codes/<org>/<name> that isn't itself a mirror → push mirror to
# github.com/<you>/<name> (created if missing), on every push and every 8h
repo=$1
if [ -z "$repo" ]; then
echo "usage: ghg-clone <repo>" >&2
exit 1
fi
# ghq natively understands https://, ssh://, git@host:…, and host/user/repo.
# Keep its output on stderr so stdout stays clean for the wrapper's cd.
ghq get "$repo" >&2 || exit
# Reduce the input to ghq's on-disk shape: <host>/<user>/<repo> —
# strip scheme (https://, ssh://, git://), user@ (git@github.com:…),
# turn scp host:path into host/path, drop trailing .git.
rel=$(printf '%s\n' "$repo" \
| sed -E 's#^[[:alnum:]]+://##; s#^[^@/]+@##; s#:#/#; s#\.git$##')
# bare "user/repo" with no host → default to github.com
case "$rel" in
*.*/*) ;;
*) rel=github.com/$rel ;;
esac
dir=$(ghq root)/$rel
if [ ! -d "$dir" ]; then
echo "ghg: expected clone at $dir but it does not exist" >&2
exit 1
fi
command -v zoxide >/dev/null 2>&1 && zoxide add "$dir"
# ── Mirrors ───────────────────────────────────────────────────────────────
# Everything below talks on stderr; stdout carries only the final path.
have() { command -v "$1" >/dev/null 2>&1; }
# prad.codes orgs are per language; guess one from GitHub's primary language.
lang_org() {
case $1 in
Go) echo go ;;
Python | "Jupyter Notebook") echo py ;;
Rust) echo rs ;;
Dart) echo dart ;;
TeX) echo tex ;;
JavaScript | TypeScript | Astro | Svelte | Vue | HTML | CSS) echo js ;;
Markdown | MDX) echo md ;;
*) echo env ;; # Lua, Shell, no code at all (configs)
esac
}
pull_mirror() { # owner name
info=$(gh repo view "$1/$2" --json viewerPermission,isPrivate,primaryLanguage \
-q '[.viewerPermission, .isPrivate, .primaryLanguage.name // ""] | @tsv' 2>/dev/null) || return 0
perm=$(printf '%s\n' "$info" | cut -f1)
private=$(printf '%s\n' "$info" | cut -f2)
lang=$(printf '%s\n' "$info" | cut -f3)
[ "$perm" = ADMIN ] || return 0 # someone else's repo: just a clone
# tea api exits 0 even on errors, hence jq -e on every response.
tea api "/api/v1/repos/search?q=$2&limit=50" | jq -e --arg u "github.com/$1/$2" \
'any(.data[]; (.original_url // "") | ascii_downcase | sub("(\\.git)?/?$"; "") | endswith($u | ascii_downcase))' \
>/dev/null && return 0
org=$(tea api '/api/v1/orgs?limit=50' | jq -r '.[].username' |
gum choose --header "Pull-mirror github.com/$1/$2 into which prad.codes org? (esc skips)" \
--selected "$(lang_org "$lang")") || return 0
[ -n "$org" ] || return 0
echo "→ pull mirror: github.com/$1/$2 → prad.codes/$org/$2 (every 8h)" >&2
priv=
[ "$private" = true ] && priv=--private
# shellcheck disable=SC2086 # $priv is one optional flag
tea repos migrate --name "$2" --owner "$org" --clone-url "https://github.com/$1/$2.git" \
--service git --mirror --mirror-interval 8h --auth-token "$(gh auth token)" $priv >&2 ||
echo "ghg: pull mirror failed" >&2
}
push_mirror() { # org name
repo=$(tea api "/api/v1/repos/$1/$2")
printf '%s' "$repo" | jq -e '.id and (.mirror | not)' >/dev/null || return 0
tea api "/api/v1/repos/$1/$2/push_mirrors" | jq -e 'length == 0' >/dev/null || return 0
me=$(gh api user -q .login 2>/dev/null) || return 0
if gh repo view "$me/$2" >/dev/null 2>&1; then
gum confirm "Push-mirror prad.codes/$1/$2 to the existing github.com/$me/$2? Mirror pushes overwrite it." || return 0
else
gum confirm "Push-mirror prad.codes/$1/$2 to a new github.com/$me/$2?" || return 0
vis=--public
[ "$(printf '%s' "$repo" | jq -r .private)" = true ] && vis=--private
gh repo create "$me/$2" "$vis" --description "Mirror of https://prad.codes/$1/$2" >&2 || return 0
fi
if jq -n --arg url "https://github.com/$me/$2.git" --arg user "$me" --arg pass "$(gh auth token)" \
'{remote_address: $url, remote_username: $user, remote_password: $pass,
interval: "8h0m0s", sync_on_commit: true}' |
tea api -d @- "/api/v1/repos/$1/$2/push_mirrors" | jq -e .remote_name >/dev/null; then
tea api -X POST "/api/v1/repos/$1/$2/push_mirrors-sync" >/dev/null # first push now
echo "→ push mirror: prad.codes/$1/$2 → github.com/$me/$2 (on push + every 8h)" >&2
else
echo "ghg: push mirror failed" >&2
fi
}
if have gh && have tea && have jq && have gum; then
owner=${rel#*/} name=${owner#*/} owner=${owner%%/*}
case $rel in
github.com/*/*) pull_mirror "$owner" "$name" ;;
prad.codes/*/*) push_mirror "$owner" "$name" ;;
esac
fi
printf '%s\n' "$dir"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Create a new private repo (prad.codes via tea, or github.com via gh),
# clone it with ghq, and print the clone directory for the shell wrapper to cd.
repo=$1
if [ -z "$repo" ]; then
echo "usage: ghn <repo-name>" >&2
exit 1
fi
host=$(gum choose "prad.codes" "github.com") || exit 1
if [ "$host" = prad.codes ]; then
owner=$(tea organizations list -o simple | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | gum choose) || exit 1
[ -n "$owner" ] || exit 1
tea repos create --name "$repo" --owner "$owner" --private >&2 || exit 1
ghq get "https://prad.codes/$owner/$repo" >&2 || exit 1
printf '%s\n' "$(ghq root)/prad.codes/$owner/$repo"
else
owner=$(gum choose "prdlk" "wltbs") || exit 1
[ -n "$owner" ] || exit 1
gh repo create "$owner/$repo" --private >&2 || exit 1
ghq get "github.com/$owner/$repo" >&2 || exit 1
printf '%s\n' "$(ghq root)/github.com/$owner/$repo"
fi
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Ghostty's `command`: every Ghostty window gets its own tmux session, never
# a mirror of another window's. Reclaim the oldest detached session (left by
# a closed window, or restored by tmux-continuum after a reboot) so closed
# windows don't pile up sessions; otherwise start a new one. pj-palette's
# background task sessions (@pj_task, named <session>:<task>) are never
# reclaimed, and targets use the session id since names may contain ':'.
# The first window after boot starts the server, which is what triggers
# continuum's restore.
# ponytail: two windows opened in the same instant can claim the same detached
# session; take a lock (flock) here if that ever bites.
# A server started here inherits this PATH. Ghostty launched from the desktop
# has no shell init, so add mise's shims for tools tmux plugins shell out to
# (bun for tmux-palette, fzf, zoxide).
[[ -d $HOME/.local/share/mise/shims ]] && PATH="$HOME/.local/share/mise/shims:$PATH"
oldest=$(tmux list-sessions -f '#{&&:#{==:#{session_attached},0},#{!:#{@pj_task}}}' \
-F '#{session_created} #{session_id}' 2>/dev/null | sort -n | head -1 | cut -d' ' -f2)
[[ -n $oldest ]] && exec tmux attach-session -t "$oldest"
exec tmux new-session
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Aggregate all AGENTS.md files in the current git repo via code2prompt.
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$repo_root" ]; then
echo "git-summary: not inside a git repository" >&2
exit 1
fi
template=$HOME/.config/code2prompt/agents-md.hbs
if [ ! -f "$template" ]; then
echo "git-summary: template not found at $template" >&2
exit 1
fi
# Pass through any extra flags (e.g. --output-file, --tokens)
exec code2prompt "$repo_root" \
--include "AGENTS.md" \
--include "**/AGENTS.md" \
--template "$template" \
"$@"
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
# Open a git repo's web page in the browser — host-agnostic, so it works for
# github.com, prad.codes (Gitea), or any other forge whose web layout is
# https://<host>/<owner>/<repo> (all of them, in practice).
#
# Usage: gop [<remote> | <owner>/<repo> | <url>]
# no arg → origin, else upstream, else the first remote of the current repo
# bare owner/repo → github.com
target=$1
url=
if [ -n "$target" ]; then
case $target in
*://* | *@*:* | */*) url=$target ;; # explicit url / scp / owner/repo
*)
url=$(git remote get-url "$target" 2>/dev/null) || {
echo "gop: no remote named '$target'" >&2
exit 1
}
;;
esac
else
for remote in origin upstream $(git remote 2>/dev/null); do
url=$(git remote get-url "$remote" 2>/dev/null) && break
url=
done
if [ -z "$url" ]; then
echo "gop: not in a git repo, or it has no remotes" >&2
exit 1
fi
fi
# Reduce any remote form to <host>/<owner>/<repo>. The web page is https
# unless the remote itself says plain http (a LAN forge, typically).
proto=https
case $url in
*://*)
# scheme form: drop scheme and credentials; drop the port for ssh/git
# (their port is not the web port), keep it for http/https.
scheme=${url%%://*}
rest=${url#*://}
rest=${rest#*@}
case $scheme in
ssh | git)
host=${rest%%/*}
rest="${host%%:*}/${rest#*/}"
;;
http) proto=http ;;
esac
url=$rest
;;
*@*:*)
# scp form: git@host:owner/repo
url=$(printf '%s\n' "${url#*@}" | sed 's#:#/#')
;;
esac
url=${url%.git}
url=${url%/}
case $url in
*/*/*) ;; # already host/owner/repo
*/*) url=github.com/$url ;;
*)
echo "gop: could not parse a repo out of: ${target:-$url}" >&2
exit 1
;;
esac
exec ${BROWSER:-xdg-open} "$proto://$url"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Save every kitty daily session (~/.config/kitty/day_session.py) right before
# shutdown/reboot.
#
# kitty-day-save.service runs this under a logind "delay" inhibitor. When a
# shutdown or reboot starts, logind broadcasts PrepareForShutdown(true) and
# waits (InhibitDelayMaxSec, 5 s by default) for the lock to be released, which
# happens when this script exits. Nothing has been killed yet at that point, so
# every kitty still has all of its windows. The flush also stops further saves
# for a minute, so windows dying during the shutdown can't shrink the saved day.
#
# Usage: kitty-day-save hold the lock, wait for a shutdown, flush, exit
# kitty-day-save --now flush every running kitty immediately
set -u
case "${1-}" in
--now)
# Every kitty on kitty.conf listens on @kitty-<pid>; ones that don't own
# a day session ignore the flush.
for s in $(grep -oE '@kitty-[0-9]+$' /proc/net/unix | sort -u); do
timeout 3 kitten @ --to "unix:$s" action kitten day_session.py flush >/dev/null 2>&1 &
done
wait
;;
--wait)
# grep returns on the first match; gdbus is left to the service's cgroup.
grep -m1 -q 'PrepareForShutdown (true' < <(
gdbus monitor --system --dest org.freedesktop.login1 --object-path /org/freedesktop/login1
) && exec "$0" --now
;;
*)
exec systemd-inhibit --what=shutdown --mode=delay --who=kitty \
--why='Saving kitty sessions' "$0" --wait
;;
esac
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Overlay omp on the current kitty window with a livediff split beside it.
#
# kitty appends -PID to config-file listen_on sockets, so a hardcoded
# --to unix:@kitty-local never matches. kitty @ resolves the real
# address from $KITTY_LISTEN_ON on its own.
if [ -z "${KITTY_LISTEN_ON:-}" ]; then
echo "launch_omp_livediff_overlay: no kitty remote-control socket (KITTY_LISTEN_ON unset)" >&2
exit 1
fi
# omp overlays the current window; capture its window id
wid=$(kitty @ launch --type=overlay-main --cwd=current omp) || exit
# livediff side by side in the same tab; keep focus on omp
exec kitty @ launch --match "id:$wid" --location=vsplit --keep-focus --cwd=current livediff
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# fzf-pick LeetCode solution files and run /leetcode-eli5 on each via omp.
root=${1:-.}
# Match only numbered problem files: 1365.some-slug.js anywhere in the tree
files=$(fd --type f '^\d+\..+\.(js|ts)$' "$root" \
| fzf --multi \
--prompt='eli5> ' \
--delimiter=/ \
--with-nth=-3.. \
--preview 'bat --color=always --line-range=:40 {}' \
--preview-window=right:60%)
[ -n "$files" ] || exit 1
printf '%s\n' "$files" | while IFS= read -r f; do
echo "── /leetcode-eli5 $f"
omp -p "/leetcode-eli5 $f"
done
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# mandible-pick — pick a tool for mandible (it requires one), then open it.
# fzf over the executables installed on PATH (no shell builtins, keywords or
# aliases). A query with a space, such as "git remote add", or one that
# matches nothing is used as typed (tool + subcommand path).
# Used by the tmux-palette "mandible" item (alias: man).
list() { # portable: no GNU find -printf
local d f
IFS=: read -ra dirs <<<"$PATH"
for d in "${dirs[@]}"; do
for f in "$d"/*; do [[ -x $f && ! -d $f ]] && printf '%s\n' "${f##*/}"; done
done
}
# enter: a query with a space ("git remote add") is a tool + subcommand path,
# taken as typed; otherwise the highlighted command, or the query if none.
q=$(list | sort -u | fzf --prompt 'mandible> ' --height 100% --reverse \
--with-shell 'bash -c' \
--bind 'enter:transform:[[ {q} == *" "* ]] && echo print-query || echo accept-or-print-query') || exit 0
[ -n "$q" ] || exit 0
# shellcheck disable=SC2086 # split "git remote" into tool + subcommands
exec mandible $q
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Fetch a URL as markdown, copy to clipboard, preview with mdfried.
url=$1
if [ -z "$url" ]; then
echo "usage: mdcp <url>" >&2
exit 1
fi
if command -v wl-copy >/dev/null 2>&1; then
copy=wl-copy paste=wl-paste
else
echo "mdcp: no clipboard tool (wl-copy) found" >&2
exit 1
fi
curl.md "$url" | "$copy"
"$paste" | mdfried
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Interactive Markdown selector and previewer.
file=$(fd --type f --extension md --max-depth 3 \
| fzf --preview 'mdfried {}' --preview-window=right:60%:wrap) || exit 0
[ -n "$file" ] || exit 0
exec mdfried "$file"
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# Sort stdin lines newest-first by mtime — the base order of the television
# cables (tv then layers match quality and frecency on top).
#
# The path is each line's last tab-separated field (the whole line when it
# has no tab). With --repo, <path>/.git/index is stat'ed first, so a repo
# ranks by its last git activity rather than its directory mtime. Lines whose
# path does not exist are dropped; ties keep their input order.
#
# Usage: <lines> | mtime-sort [--repo]
exec perl -e '
my $repo = @ARGV && $ARGV[0] eq "--repo";
my ($i, @rows) = (0);
while (my $line = <STDIN>) {
chomp $line;
my $path = (split /\t/, $line)[-1];
next unless defined $path && length $path;
my $mtime;
$mtime = (stat "$path/.git/index")[9] if $repo;
$mtime = (stat $path)[9] unless defined $mtime;
push @rows, [$mtime, $i++, $line] if defined $mtime;
}
print "$_->[2]\n" for sort { $b->[0] <=> $a->[0] || $a->[1] <=> $b->[1] } @rows;
' -- "$@"
+189
View File
@@ -0,0 +1,189 @@
#!/bin/bash
# omarchy:summary=Cycle through the windows of an app, or launch it if none are open
# omarchy:args=<app|browser|terminal|ai|webapp|webapp-NAME|kitty-CLASS>
set -uo pipefail
APP="${1:-}"
if [[ -z $APP ]]; then
echo "Usage: omarchy-cycle <app|browser|terminal|ai|webapp|webapp-NAME|kitty-CLASS>"
exit 1
fi
# Per-class launch commands for kitty-* (class -> command run via `kitty -e`).
# Unmapped classes just open a plain kitty with that class.
declare -A KITTY_CMDS=(
# [kitty-lazygit]="lazygit"
# [kitty-btop]="btop"
)
# --- Hyprland helpers --------------------------------------------------------
# Print the addresses of all windows whose class or initialClass matches the
# regex in $1. An optional regex in $2 excludes matches again — used to keep
# scratch windows out of a cycle. Windows are sorted by workspace, then by
# address, so the cycle order stays the same between calls.
addresses_matching() {
hyprctl clients -j | jq -r --arg re "$1" --arg not "${2:-}" '
[ .[]
| select(((.class // "") | test($re; "i")) or ((.initialClass // "") | test($re; "i")))
| select($not == "" or (
(((.class // "") | test($not; "i")) or ((.initialClass // "") | test($not; "i"))) | not
))
]
| sort_by(.workspace.id, .address)
| .[].address'
}
# Same, but drops site-specific app (SSA) windows, so real browsers stay apart
# from web apps that share a class prefix.
addresses_matching_no_ssa() {
hyprctl clients -j | jq -r --arg re "$1" '
[ .[]
| select(((.class // "") | test($re; "i")) or ((.initialClass // "") | test($re; "i")))
| select(((.class // "") | test("^(chrome-|webapp-)"; "i")) | not)
]
| sort_by(.workspace.id, .address)
| .[].address'
}
focused_address() {
hyprctl activewindow -j 2>/dev/null | jq -r '.address // empty'
}
focus_window() {
hyprctl dispatch "hl.dsp.focus({ window = \"address:$1\" })" >/dev/null 2>&1 ||
hyprctl dispatch focuswindow "address:$1" >/dev/null
}
spawn() {
exec setsid uwsm-app -- $1
}
# --- Find the windows --------------------------------------------------------
declare -a INSTANCES
if [[ $APP == "webapp" ]]; then
# Cycle all web apps: Firefox SSAs (WebApp-*) and Chromium/Brave SSAs (chrome-*).
# Real browsers are excluded, because their class has no SSA prefix.
INSTANCES=($(addresses_matching '^(webapp-|chrome-)'))
FALLBACK_APP="webapp-youtube"
elif [[ $APP == webapp-* ]]; then
# Extract the web app name (e.g. "webapp-claude" -> "claude").
WEBAPP_NAME="${APP#webapp-}"
# Firefox SSAs use the class WebApp-<Name>. Chromium SSAs use
# chrome-<host>__-Default, so the name is matched anywhere in the class.
INSTANCES=($(addresses_matching "webapp-${WEBAPP_NAME}|^chrome-.*${WEBAPP_NAME}"))
if ((${#INSTANCES[@]} == 0)); then
# No window is open. Launch from the desktop entry.
DESKTOP_FILE=$(find ~/.local/share/applications -iname "WebApp-${WEBAPP_NAME}*.desktop" -print -quit 2>/dev/null)
[[ -n $DESKTOP_FILE ]] ||
DESKTOP_FILE=$(find ~/.local/share/applications -iname "*${WEBAPP_NAME}*.desktop" -print -quit 2>/dev/null)
if [[ -n $DESKTOP_FILE ]]; then
exec setsid gtk-launch "$(basename "$DESKTOP_FILE")"
else
echo "No desktop file found for web app: ${WEBAPP_NAME}"
exit 1
fi
fi
elif [[ $APP == "kitty" ]]; then
# Cycle all kitty windows, whatever the class (kitty, kitty-nvim, ...).
INSTANCES=($(addresses_matching '^kitty'))
DEFAULT_SPAWN="kitty"
elif [[ $APP == kitty-* ]]; then
# One kitty class, e.g. kitty-nvim -> class "kitty-nvim".
INSTANCES=($(addresses_matching "^${APP}$"))
if ((${#INSTANCES[@]} == 0)); then
cmd="${KITTY_CMDS[$APP]:-}"
if [[ -n $cmd ]]; then
spawn "kitty --class $APP -e $cmd"
else
spawn "kitty --class $APP"
fi
fi
elif [[ $APP == "ghostty" ]]; then
# Cycle Ghostty terminals (class com.mitchellh.ghostty).
INSTANCES=($(addresses_matching 'ghostty'))
DEFAULT_SPAWN="ghostty"
elif [[ $APP == "browser" ]]; then
# Cycle all browsers. Web apps are left out.
INSTANCES=($(addresses_matching_no_ssa '^(zen|brave|chromium|google-chrome|firefox|librewolf|vivaldi|microsoft-edge|helium)'))
DEFAULT_LAUNCHER="omarchy-launch-browser"
elif [[ $APP == "brave" ]]; then
# Cycle the Brave Origin browser and all of its web apps. Both the
# real browser (class brave-origin) and the SSAs (class brave-<host>)
# share the brave- prefix, so one regex catches them all.
INSTANCES=($(addresses_matching '^brave'))
DEFAULT_LAUNCHER="omarchy-launch-browser"
elif [[ $APP == "terminal" ]]; then
# Cycle all terminals. Tern (so.stencil.tern) is the SUPER + A scratch
# terminal and the pattern below already leaves it out.
INSTANCES=($(addresses_matching '(kitty|alacritty|ghostty|foot|wezterm)'))
DEFAULT_LAUNCHER="omarchy-launch-terminal"
elif [[ $APP == "ai" ]]; then
# Cycle the AI desktop apps (Claude/Hermes).
INSTANCES=($(addresses_matching '(claude|hermes)'))
DEFAULT_SPAWN="hermes desktop"
else
# Any other app, matched on the full class.
INSTANCES=($(addresses_matching "^${APP}$"))
fi
# --- Launch if nothing is open -----------------------------------------------
if ((${#INSTANCES[@]} == 0)); then
if [[ -n ${DEFAULT_LAUNCHER:-} ]]; then
exec setsid "$DEFAULT_LAUNCHER"
fi
if [[ -n ${DEFAULT_SPAWN:-} ]]; then
spawn "$DEFAULT_SPAWN"
fi
if [[ -n ${FALLBACK_APP:-} ]]; then
exec "$0" "$FALLBACK_APP"
fi
if [[ $APP == "webapp" ]]; then
echo "No ${APP} windows running"
exit 0
fi
spawn "$APP"
fi
# --- Focus, or cycle to the next window --------------------------------------
if ((${#INSTANCES[@]} == 1)); then
focus_window "${INSTANCES[0]}"
exit 0
fi
FOCUSED=$(focused_address)
# If the focused window is one of ours, go to the next one. If it is not,
# go to the first one.
next_index=0
for i in "${!INSTANCES[@]}"; do
if [[ -n $FOCUSED && ${INSTANCES[$i]} == "$FOCUSED" ]]; then
next_index=$(((i + 1) % ${#INSTANCES[@]}))
break
fi
done
focus_window "${INSTANCES[$next_index]}"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Cycle Brave Origin windows and all its web apps; launch brave-origin if none are open.
exec "$(dirname "$(readlink -f "$0")")/omarchy-cycle" brave
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Cycle through all terminals (ghostty/kitty/alacritty/foot/wezterm); launch the default if none are open.
exec "$(dirname "$(readlink -f "$0")")/omarchy-cycle" terminal
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# omarchy:summary=Cycle the focused window through preset sizes
# omarchy:args=[next|prev]
set -uo pipefail
DIRECTION="${1:-next}"
# Preset widths, as a fraction of the screen. Edit to taste.
# These only apply to dwindle and to floating windows. On a scrolling
# workspace the layout cycles its own list instead. Set that list with:
# hl.config({ scrolling = { explicit_column_widths = "0.333, 0.5, 0.667, 1.0" } })
FRACTIONS=(0.333 0.5 0.667)
# Height of a floating window, as a fraction of the screen.
FLOATING_HEIGHT=0.8
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/window-sizes"
# Try the Lua dispatcher first, then fall back to the legacy name.
hypr_dispatch() {
local lua="$1"
shift
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
}
calc() {
awk "BEGIN { printf \"%s\", $1 }"
}
ACTIVE=$(hyprctl activewindow -j 2>/dev/null)
[[ -n $ACTIVE && $ACTIVE != "{}" ]] || exit 0
ADDRESS=$(jq -r '.address // empty' <<<"$ACTIVE")
FLOATING=$(jq -r '.floating // false' <<<"$ACTIVE")
WORKSPACE=$(jq -r '.workspace.id // empty' <<<"$ACTIVE")
LAYOUT=$(hyprctl activeworkspace -j 2>/dev/null | jq -r '.tiledLayout // empty')
# --- Scrolling: the layout keeps its own preset list ------------------------
if [[ $FLOATING != "true" && $LAYOUT == "scrolling" ]]; then
if [[ $DIRECTION == "prev" ]]; then
MSG="colresize -conf"
else
MSG="colresize +conf"
fi
hypr_dispatch "hl.dsp.layout(\"$MSG\")" layoutmsg "$MSG"
exit 0
fi
# --- Step to the next preset ------------------------------------------------
# Floating windows keep an index of their own. Tiled windows share one index
# per workspace, because the split is a property of the workspace.
if [[ $FLOATING == "true" ]]; then
STATE_KEY="window-${ADDRESS}"
else
STATE_KEY="workspace-${WORKSPACE}"
fi
STATE_FILE="$STATE_DIR/${STATE_KEY}.index"
COUNT=${#FRACTIONS[@]}
INDEX=0
if [[ -f $STATE_FILE ]]; then
INDEX=$(<"$STATE_FILE")
[[ $INDEX =~ ^[0-9]+$ ]] || INDEX=0
fi
if [[ $DIRECTION == "prev" ]]; then
INDEX=$(((INDEX - 1 + COUNT) % COUNT))
else
INDEX=$(((INDEX + 1) % COUNT))
fi
mkdir -p "$STATE_DIR"
printf '%s\n' "$INDEX" >"$STATE_FILE"
FRACTION=${FRACTIONS[$INDEX]}
# --- Floating: set an absolute size and center ------------------------------
if [[ $FLOATING == "true" ]]; then
read -r MON_W MON_H < <(hyprctl monitors -j |
jq -r '.[] | select(.focused) | "\(((.width / .scale) | floor)) \(((.height / .scale) | floor))"')
[[ -n ${MON_W:-} && -n ${MON_H:-} ]] || exit 1
WIDTH=$(calc "int($MON_W * $FRACTION)")
HEIGHT=$(calc "int($MON_H * $FLOATING_HEIGHT)")
hypr_dispatch "hl.dsp.window.resize({ x = $WIDTH, y = $HEIGHT })" resizeactive exact "$WIDTH" "$HEIGHT"
hypr_dispatch "hl.dsp.window.center()" centerwindow
exit 0
fi
# --- Dwindle: set the split ratio -------------------------------------------
# splitratio 1.0 is an even split, so a fraction of 0.5 maps to 1.0.
# With the default split_bias of 0, the ratio goes to the left or top window.
# So when the focused window is the right or bottom one, the fraction is
# mirrored. Which side the window sits on is read from its position.
read -r WIN_X WIN_Y WIN_W WIN_H < <(jq -r '"\(.at[0]) \(.at[1]) \(.size[0]) \(.size[1])"' <<<"$ACTIVE")
read -r MON_X MON_Y MON_W MON_H < <(hyprctl monitors -j |
jq -r '.[] | select(.focused) | "\(.x) \(.y) \(((.width / .scale) | floor)) \(((.height / .scale) | floor))"')
MIRROR=false
if ((WIN_W * 100 > MON_W * 90)); then
# The window spans the screen, so the split is top and bottom.
((WIN_Y + WIN_H / 2 > MON_Y + MON_H / 2)) && MIRROR=true
else
((WIN_X + WIN_W / 2 > MON_X + MON_W / 2)) && MIRROR=true
fi
if [[ $MIRROR == "true" ]]; then
RATIO=$(calc "2 * (1 - $FRACTION)")
else
RATIO=$(calc "2 * $FRACTION")
fi
# Hyprland accepts 0.1 to 1.9.
RATIO=$(calc "($RATIO < 0.1 ? 0.1 : ($RATIO > 1.9 ? 1.9 : $RATIO))")
hypr_dispatch "hl.dsp.layout(\"splitratio $RATIO exact\")" layoutmsg "splitratio $RATIO exact"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# omarchy:summary=Launch the default browser; a cold Brave Origin start reopens the last session without the "Restore pages?" prompt.
# omarchy:args=[url]
# Shadows /usr/bin/omarchy-launch-browser (~/.local/bin is earlier on PATH)
# and hands off to it. A reboot or power-off kills Brave uncleanly, so the
# next start counts as post-crash: Chromium then skips session restore and
# shows the "Restore pages?" bubble instead. --hide-crash-restore-bubble
# clears that post-crash state, and --restore-last-session reopens every
# window. Added only on a cold, non-private start: a running browser ignores
# both, and Omarchy's launcher would read the first flag as the URL to focus.
if [[ " $* " != *" --private "* ]] &&
[[ $(env -u BROWSER xdg-settings get default-web-browser) == brave-origin.desktop ]] &&
! pgrep -f '^/opt/brave-origin-bin/brave( |$)' >/dev/null; then
set -- --restore-last-session --hide-crash-restore-bubble "$@"
fi
exec /usr/bin/omarchy-launch-browser "$@"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# omarchy:summary=Toggle a window between picture-in-picture (floating, pinned, bottom-left corner of the focused monitor) and tiled.
# omarchy:args=[toggle|on] [address:0x...]
set -uo pipefail
# Usage: omarchy-pip [toggle|on] [address:0x...]
#
# "on" always fits the window: float, pin, resize to FRACTION of the focused
# monitor on both axes and park it in the bottom-left corner, inset by GAP.
# "toggle" (the default) does that unless the window is already floating and
# pinned, in which case it unpins and tiles it again. The window defaults to
# the active one. omarchy-summon calls "on" after carrying its window to the
# focused monitor; the YouTube window rule in hypr/hyprland.lua mirrors the
# same fraction and inset for the first open, so keep the three in sync.
MODE=${1:-toggle}
WINDOW=${2:-}
case $MODE in
toggle | on) ;;
*)
echo "usage: ${0##*/} [toggle|on] [address:0x...]" >&2
exit 2
;;
esac
FRACTION=0.3
# Matches general:gaps_out so the window lines up with tiled neighbours and
# its border stays on screen.
GAP=10
# Lua dispatcher first, classic name as the pre-lua fallback (same shape as
# the other omarchy-* scripts here).
hypr_dispatch() {
local lua="$1"
shift
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
}
if [[ -z $WINDOW ]]; then
ACTIVE=$(hyprctl activewindow -j 2>/dev/null)
[[ -n $ACTIVE && $ACTIVE != "{}" ]] || exit 0
WINDOW="address:$(jq -r '.address' <<<"$ACTIVE")"
else
ACTIVE=$(hyprctl clients -j | jq -c --arg a "${WINDOW#address:}" 'first(.[] | select(.address == $a)) // empty')
[[ -n $ACTIVE ]] || exit 1
fi
# The wiki documents action = "set", but on Hyprland 0.56 "set" toggles;
# "on" / "off" are what the dispatcher actually honours.
if [[ $MODE == toggle ]] && [[ $(jq -r '.floating and .pinned' <<<"$ACTIVE") == true ]]; then
hypr_dispatch "hl.dsp.window.pin({ action = \"off\", window = \"$WINDOW\" })" pin "$WINDOW"
hypr_dispatch "hl.dsp.window.float({ action = \"off\", window = \"$WINDOW\" })" settiled "$WINDOW"
exit 0
fi
# Focused monitor: origin and logical size.
read -r MON_X MON_Y MON_W MON_H < <(hyprctl monitors -j |
jq -r '.[] | select(.focused) | "\(.x) \(.y) \(((.width / .scale) | floor)) \(((.height / .scale) | floor))"')
[[ -n ${MON_H:-} ]] || exit 1
WIDTH=$(awk "BEGIN { printf \"%d\", $MON_W * $FRACTION }")
HEIGHT=$(awk "BEGIN { printf \"%d\", $MON_H * $FRACTION }")
X=$((MON_X + GAP))
Y=$((MON_Y + MON_H - HEIGHT - GAP))
hypr_dispatch "hl.dsp.window.float({ action = \"on\", window = \"$WINDOW\" })" setfloating "$WINDOW"
hypr_dispatch "hl.dsp.window.pin({ action = \"on\", window = \"$WINDOW\" })" pin "$WINDOW"
hypr_dispatch "hl.dsp.window.resize({ x = $WIDTH, y = $HEIGHT, window = \"$WINDOW\" })" resizewindowpixel "exact $WIDTH $HEIGHT,$WINDOW"
hypr_dispatch "hl.dsp.window.move({ x = $X, y = $Y, window = \"$WINDOW\" })" movewindowpixel "exact $X $Y,$WINDOW"
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# omarchy:summary=Move the focused window into a neighboring group or swap with the neighbor; floating windows go to a screen corner
# omarchy:args=<l|r|u|d>
set -uo pipefail
DIR="${1:-}"
case "$DIR" in
l | r | u | d) ;;
*)
echo "Usage: omarchy-smart-move <l|r|u|d>"
exit 1
;;
esac
hypr_dispatch() {
local lua="$1"
shift
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
}
ACTIVE=$(hyprctl activewindow -j 2>/dev/null)
[[ -n $ACTIVE && $ACTIVE != "{}" ]] || exit 0
ADDRESS=$(jq -r '.address // empty' <<<"$ACTIVE")
WORKSPACE=$(jq -r '.workspace.id // empty' <<<"$ACTIVE")
GROUPED=$(jq -r '(.grouped // []) | length' <<<"$ACTIVE")
[[ -n $ADDRESS && -n $WORKSPACE ]] || exit 0
# A floating window has no neighbours to swap with, so the four keys park it
# in a corner of its monitor instead, size unchanged, inside the reserved
# area (the bar) and inset by GAP (matches general:gaps_out, same as
# omarchy-pip). h/j/k/l read clockwise from the bottom left: l bottom-left,
# d top-left, u top-right, r bottom-right.
if [[ $(jq -r '.floating' <<<"$ACTIVE") == true ]]; then
GAP=10
read -r WIN_W WIN_H MON_ID < <(jq -r '"\(.size[0]) \(.size[1]) \(.monitor)"' <<<"$ACTIVE")
read -r MON_X MON_Y MON_W MON_H RES_L RES_T RES_R RES_B < <(hyprctl monitors -j | jq -r --argjson id "$MON_ID" \
'.[] | select(.id == $id) | "\(.x) \(.y) \(((.width / .scale) | floor)) \(((.height / .scale) | floor)) \(.reserved | join(" "))"')
[[ -n ${RES_B:-} ]] || exit 1
LEFT=$((MON_X + RES_L + GAP))
RIGHT=$((MON_X + MON_W - RES_R - WIN_W - GAP))
TOP=$((MON_Y + RES_T + GAP))
BOTTOM=$((MON_Y + MON_H - RES_B - WIN_H - GAP))
case $DIR in
l) X=$LEFT Y=$BOTTOM ;;
d) X=$LEFT Y=$TOP ;;
u) X=$RIGHT Y=$TOP ;;
r) X=$RIGHT Y=$BOTTOM ;;
esac
hypr_dispatch "hl.dsp.window.move({ x = $X, y = $Y })" movewindowpixel "exact $X $Y,address:$ADDRESS"
exit 0
fi
# Find the nearest window in the given direction. A window counts as a
# neighbor only when it overlaps the focused window on the other axis, so a
# window in a far corner is not picked.
NEIGHBOR_GROUPED=$(hyprctl clients -j | jq -r \
--arg address "$ADDRESS" \
--arg dir "$DIR" \
--argjson workspace "$WORKSPACE" '
( [ .[] | select(.address == $address) ] | first ) as $self
| if $self == null then "none" else
( $self.at[0] + $self.size[0] / 2 ) as $sx
| ( $self.at[1] + $self.size[1] / 2 ) as $sy
| [ .[]
| select(.address != $address)
| select(.workspace.id == $workspace)
| select(.floating == false)
| select(.hidden == false)
| . + {
cx: (.at[0] + .size[0] / 2),
cy: (.at[1] + .size[1] / 2)
}
| select(
if $dir == "l" then .cx < $sx and (.at[1] < ($self.at[1] + $self.size[1])) and (($self.at[1]) < (.at[1] + .size[1]))
elif $dir == "r" then .cx > $sx and (.at[1] < ($self.at[1] + $self.size[1])) and (($self.at[1]) < (.at[1] + .size[1]))
elif $dir == "u" then .cy < $sy and (.at[0] < ($self.at[0] + $self.size[0])) and (($self.at[0]) < (.at[0] + .size[0]))
else .cy > $sy and (.at[0] < ($self.at[0] + $self.size[0])) and (($self.at[0]) < (.at[0] + .size[0]))
end
)
]
| sort_by(
if $dir == "l" or $dir == "r" then (.cx - $sx | fabs) else (.cy - $sy | fabs) end
)
| first
| if . == null then "none"
elif ((.grouped // []) | length) > 0 then "group"
else "single"
end
end')
# A group on either side means the move is group aware: Hyprland puts the
# window into the neighboring group, or takes it out of its own group at the
# edge. With no group in play, swap places with the neighbor instead.
if [[ $NEIGHBOR_GROUPED == "group" || $GROUPED -gt 1 ]]; then
hypr_dispatch "hl.dsp.window.move({ direction = \"$DIR\", group_aware = true })" movewindoworgroup "$DIR"
else
hypr_dispatch "hl.dsp.window.swap({ direction = \"$DIR\" })" swapwindow "$DIR"
fi
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# omarchy:summary=Launch a floating pinned app (youtube) or bring its window to the focused monitor's bottom-left corner.
set -uo pipefail
# Usage: omarchy-summon [youtube]
#
# Each profile is one app kept as a picture-in-picture window: the window
# rule in hypr/hyprland.lua floats, pins, sizes and parks it when it first
# opens, so a plain launch is enough when no window exists. When one does,
# this script carries it to the focused monitor and hands it to omarchy-pip
# to fit it there again: the two panels differ in logical size, and Hyprland
# keeps a floating window's pixel size and offset across a monitor move.
#
# PATTERN is an ERE over the window class. A web app's picture-in-picture
# overlay shares the class, so windows titled Picture-in-Picture are skipped
# (Omarchy's own pip rule owns those).
case ${1:-youtube} in
youtube)
# Chromium derives the class from the app URL, and the same web app has
# shown up under more than one host spelling (youtube.com vs
# www.youtube.com) once a video is playing, so match the family.
PATTERN='^brave-(www\.)?youtube\.com__.*$'
LAUNCH="gtk-launch YouTube"
;;
*)
echo "usage: ${0##*/} [youtube]" >&2
exit 2
;;
esac
# Lua dispatcher first, classic name as the pre-lua fallback (same shape as
# the other omarchy-* scripts here).
hypr_dispatch() {
local lua="$1"
shift
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
}
ADDRESS=$(hyprctl clients -j | jq -r --arg re "$PATTERN" '
first(.[] | select((.class | test($re)) and ((.title | test("Picture.?in.?[Pp]icture")) | not)))
| .address // empty')
if [[ -z $ADDRESS ]]; then
exec setsid uwsm-app -- $LAUNCH >/dev/null 2>&1
fi
WINDOW="address:$ADDRESS"
MON_NAME=$(hyprctl monitors -j | jq -r '.[] | select(.focused) | .name')
[[ -n $MON_NAME ]] || exit 1
hypr_dispatch "hl.dsp.window.move({ monitor = \"$MON_NAME\", window = \"$WINDOW\" })" movewindow "mon:$MON_NAME,$WINDOW"
omarchy-pip on "$WINDOW"
hypr_dispatch "hl.dsp.focus({ window = \"$WINDOW\" })" focuswindow "$WINDOW"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# omarchy:summary=Toggle the workspace layout, and set every column to one width when it becomes scrolling
# omarchy:args=[width]
set -uo pipefail
# Column width, as a fraction of the screen, applied to every column on the
# workspace when it turns to scrolling.
WIDTH="${1:-0.75}"
hypr_dispatch() {
local lua="$1"
shift
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
}
current_layout() {
hyprctl activeworkspace -j 2>/dev/null | jq -r '.tiledLayout // empty'
}
omarchy-hyprland-workspace-layout-toggle || exit 1
# The toggle writes a workspace rule, so the new layout can take a moment to
# show up. Read until it does, then act only on the scrolling case: dwindle
# re-tiles on its own and needs no widths.
for _ in {1..6}; do
case "$(current_layout)" in
scrolling)
MSG="colresize all $WIDTH"
hypr_dispatch "hl.dsp.layout(\"$MSG\")" layoutmsg "$MSG"
exit 0
;;
dwindle)
exit 0
;;
esac
sleep 0.05
done
+134
View File
@@ -0,0 +1,134 @@
#!/bin/bash
# omarchy:summary=Toggle a scratch workspace (scratchpad, claude, fastmail, beeper, home, lmstudio), starting its resident apps when any is missing.
set -uo pipefail
# Usage: omarchy-toggle-scratch [scratchpad|claude|fastmail|beeper|home|lmstudio]
#
# Each profile is a special workspace, the monitor it opens on, and its
# resident apps (class pattern -> launch command; the pattern is an anchored
# ERE, so a plain class name matches itself). hypr/hyprland.lua pins every
# class to its workspace, so only the launch and the reveal are left here. A
# resident is launched only when no window on the workspace matches it, which
# is what keeps a launcher that always opens a fresh window (the Chromium
# web apps) from piling up duplicates. The scrolling layout gives each
# resident its own column, in the order they appear.
#
# HOME_MONITOR lists connector names or EDID descriptions, separated by |;
# the first one connected wins (descriptions, as in the workspace rules,
# survive a different DP connector after re-docking). When it is empty or
# none is connected, the workspace opens on the focused monitor. EXTERNAL is
# the main external display: the Studio Display, else the Samsung ultrawide
# (same order as hypr/monitors.lua).
EXTERNAL="Apple Computer Inc StudioDisplay 0x984262A7|Samsung Electric Company S34CG50 HNTY501103"
HOME_MONITOR=""
case ${1:-scratchpad} in
scratchpad)
NAME="scratchpad"
HOME_MONITOR=$EXTERNAL
MEMBERS=("so.stencil.tern|tern")
;;
claude)
NAME="claude"
HOME_MONITOR=$EXTERNAL
MEMBERS=("com.anthropic.Claude|claude-desktop")
;;
fastmail)
NAME="fastmail"
HOME_MONITOR="eDP-1"
MEMBERS=("com.fastmail.Fastmail|gtk-launch com.fastmail.Fastmail")
;;
beeper)
NAME="beeper"
HOME_MONITOR="eDP-1"
MEMBERS=("Beeper|gtk-launch beeper")
;;
home)
NAME="home"
MEMBERS=("brave-home\.google\.com__.*|gtk-launch 'Google Home'")
;;
lmstudio)
NAME="lmstudio"
MEMBERS=("ai\.elementlabs\.lmstudio|gtk-launch ai.elementlabs.lmstudio")
;;
*)
echo "usage: ${0##*/} [scratchpad|claude|fastmail|beeper|home|lmstudio]" >&2
exit 2
;;
esac
WORKSPACE="special:$NAME"
# Classes of the windows living on the scratchpad, shown or hidden.
scratch_classes() {
hyprctl clients -j | jq -r --arg ws "$WORKSPACE" '.[] | select(.workspace.name == $ws) | .class'
}
scratch_windows() {
scratch_classes | wc -l
}
# Name of the monitor currently displaying the scratchpad, if any.
scratch_monitor() {
hyprctl monitors -j | jq -r --arg ws "$WORKSPACE" '.[] | select(.specialWorkspace.name == $ws) | .name'
}
scratch_visible() {
[[ -n $(scratch_monitor) ]]
}
# Hyprland's lua config takes lua expressions on `hyprctl dispatch`; the
# classic dispatcher is the pre-lua fallback (same shape as omarchy-cycle).
focus_monitor() {
hyprctl dispatch "hl.dsp.focus({ monitor = \"$1\" })" >/dev/null 2>&1 ||
hyprctl dispatch focusmonitor "$1" >/dev/null
}
# togglespecialworkspace acts on the focused monitor, so focus decides where
# the scratchpad appears (or which copy hides).
toggle_on() {
focus_monitor "$1"
hyprctl dispatch "hl.dsp.workspace.toggle_special(\"$NAME\")" >/dev/null 2>&1 ||
hyprctl dispatch togglespecialworkspace "$NAME" >/dev/null
}
# The first connected home monitor, else whatever is focused.
target_monitor() {
hyprctl monitors -j | jq -r --arg m "$HOME_MONITOR" '
. as $monitors
| first(($m | split("|"))[] as $want | $monitors[] | select(.name == $want or .description == $want))
// first($monitors[] | select(.focused))
| .name'
}
# Showing: just hide, even if a member is gone. Missing members come back on
# the next reveal.
shown=$(scratch_monitor)
if [[ -n $shown ]]; then
toggle_on "$shown"
exit 0
fi
present=$(scratch_classes)
before=$(grep -c . <<<"$present")
launched=0
for member in "${MEMBERS[@]}"; do
pattern=${member%%|*}
grep -qE "^(${pattern})$" <<<"$present" && continue
# eval so a launch command can quote a desktop id with spaces.
eval "setsid uwsm-app -- ${member#*|}" >/dev/null 2>&1 &
launched=1
done
if ((launched)); then
# Reveal once the first newcomer lands (Claude takes a few seconds); any
# later ones drop straight onto the now-visible scratchpad. Deciding the
# reveal after the window lands keeps this correct whether or not the rule
# already showed the workspace.
for _ in {1..80}; do
sleep 0.1
(($(scratch_windows) > before)) && break
done
fi
scratch_visible || toggle_on "$(target_monitor)"
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""omp-session — start or resume omp for the current git repo (ctrl-a in fish
and zsh), and the backend of the television `omp-sessions` cable
(~/.config/television/cable/omp-sessions.toml).
Launch rules, inside a git repo:
default branch resume the newest session on it if it was written less than
3 hours ago; otherwise pick one with `tv omp-sessions`
(all sessions of the repo). No sessions at all → new session.
other branch resume the newest session on it; none → new session.
detached HEAD pick with `tv omp-sessions` (no sessions → new session).
Outside a git repo: plain `omp`.
A session belongs to the repo when its cwd is the worktree root or below it,
excluding worktrees nested inside it. omp does not record the git branch, so
a session's branch is the branch HEAD was on (per `git reflog`) when the
session file was last written.
Cable entry points:
omp-session --list one "display<TAB>session file" record per session
omp-session --preview FILE title, cwd, and the conversation's message text
omp-session --resume FILE the cable's enter action: `omp --resume FILE`
omp-session --new the cable's ctrl-n action: a new `omp` session
Env:
OMP_SESSION_STATE=FILE --resume/--new write `ran` to it, so the launcher can
tell a started session from a cancelled picker and
only move the cursor back up after a cancel (tv
--inline).
PI_CODING_AGENT_DIR omp's agent dir (default ~/.omp/agent).
"""
import json
import os
import re
import signal
import subprocess
import sys
import tempfile
import time
from pathlib import Path
RECENT = 3 * 3600
SESSIONS = Path(os.environ.get("PI_CODING_AGENT_DIR") or Path.home() / ".omp" / "agent") / "sessions"
# tv closes the preview pipe early on long sessions; exit quietly.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
MOVES = (
# (pattern, group of the branch HEAD moved to, group of the branch it left)
(re.compile(r"checkout: moving from (.+) to (.+)$"), 2, 1),
(re.compile(r"Branch: renamed refs/heads/(.+) to refs/heads/(.+)$"), 2, 1),
# rebase (start) detaches HEAD; the finish returns to the rebased branch.
(re.compile(r"rebase.*\(finish\): returning to refs/heads/(.+)$"), 1, 1),
)
def git(*args):
r = subprocess.run(["git", *args], capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else ""
def inside(path, root):
return path == root or path.startswith(root.rstrip("/") + "/")
class Session:
__slots__ = ("path", "mtime", "cwd", "title")
def __init__(self, path, mtime, cwd, title):
self.path, self.mtime, self.cwd, self.title = path, mtime, cwd, title
def header(path):
"""(cwd, title) from the head of a session file: an optional title record,
then the session record."""
title = None
try:
with open(path, encoding="utf-8", errors="replace") as fh:
for _, line in zip(range(4), fh):
rec = json.loads(line)
if rec.get("type") == "title":
title = rec.get("title")
elif rec.get("type") == "session":
return rec.get("cwd"), title
except (OSError, ValueError):
pass
return None
def repo_sessions(top):
"""Sessions whose cwd is inside the worktree `top`, newest first."""
nested = [
line[len("worktree "):]
for line in git("worktree", "list", "--porcelain").splitlines()
if line.startswith("worktree ")
]
nested = [w for w in nested if w != top and inside(w, top)]
out = []
for path in SESSIONS.glob("*/*.jsonl"):
h = header(path)
if not h or not h[0]:
continue
cwd, title = h
if not inside(cwd, top) or any(inside(cwd, w) for w in nested):
continue
try:
mtime = path.stat().st_mtime
except OSError:
continue
out.append(Session(path, mtime, cwd, title))
out.sort(key=lambda s: s.mtime, reverse=True)
return out
class Timeline:
"""Which branch HEAD was on at a given time, from this worktree's reflog."""
def __init__(self, current):
self.current = current
self.moves = [] # (unix time, to, from), newest first
for line in git("reflog", "--date=unix", "--format=%gd%x09%gs").splitlines():
ref, _, subject = line.partition("\t")
m = re.search(r"@\{(\d+)\}", ref)
if not m:
continue
for pat, to, frm in MOVES:
hit = pat.search(subject)
if hit:
self.moves.append((int(m.group(1)), hit.group(to), hit.group(frm)))
break
def branch_at(self, t):
for ts, to, _ in self.moves:
if ts <= t:
return to
# Older than every recorded move: the branch the oldest move left.
return self.moves[-1][2] if self.moves else self.current
def default_branch():
ref = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
if ref:
return ref.split("/", 1)[1]
for b in (git("config", "init.defaultBranch"), "main", "master"):
if b and git("rev-parse", "--verify", "--quiet", f"refs/heads/{b}"):
return b
return ""
def first_user_text(path):
try:
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if '"role":"user"' not in line:
continue
text = message_text(json.loads(line).get("message") or {})
if text.strip():
return text.strip().splitlines()[0]
except (OSError, ValueError):
pass
return ""
def message_text(msg):
content = msg.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text")
return ""
def age(seconds):
for unit, size in (("w", 604800), ("d", 86400), ("h", 3600), ("m", 60)):
if seconds >= size:
return f"{int(seconds // size)}{unit}"
return "now"
# ── Cable entry points ─────────────────────────────────────────────
def cmd_list():
top = git("rev-parse", "--show-toplevel")
if not top:
return
sessions = repo_sessions(top)
timeline = Timeline(git("branch", "--show-current"))
now = time.time()
rows = []
for s in sessions:
branch = timeline.branch_at(s.mtime)
title = s.title or first_user_text(s.path) or s.path.stem
sub = os.path.relpath(s.cwd, top)
rows.append((age(now - s.mtime), branch, title + ("" if sub == "." else f" · {sub}"), s.path))
width = min(max((len(r[1]) for r in rows), default=0), 24)
for when, branch, title, path in rows:
print(f"{when:>4} {branch[:width]:<{width}} {title}\t{path}")
def cmd_preview(path):
path = Path(path)
h = header(path)
cwd, title = h if h else ("", None)
updated = time.strftime("%Y-%m-%d %H:%M", time.localtime(path.stat().st_mtime))
print(f"\033[1m{title or path.stem}\033[0m")
print(f"\033[2m{updated} · {cwd}\033[0m")
colors = {"user": "\033[1;36m", "assistant": "\033[1;35m"}
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if '"type":"message"' not in line:
continue
try:
msg = json.loads(line).get("message") or {}
except ValueError:
continue
role = msg.get("role")
text = message_text(msg).strip() if role in colors else ""
if text:
print(f"\n{colors[role]}▌ {role}\033[0m\n{text}")
def mark_ran():
state = os.environ.get("OMP_SESSION_STATE")
if state:
Path(state).write_text("ran\n")
def cmd_resume(path):
mark_ran()
os.execvp("omp", ["omp", "--resume", str(path)])
# ── Launcher ───────────────────────────────────────────────────────
def new_session():
mark_ran()
os.execvp("omp", ["omp"])
def pick(sessions):
if not sessions:
new_session()
fd, state = tempfile.mkstemp(prefix="omp-session.")
os.close(fd)
try:
subprocess.run(["tv", "omp-sessions", "--inline"], env={**os.environ, "OMP_SESSION_STATE": state})
ran = Path(state).read_text().strip() == "ran"
finally:
os.unlink(state)
if not ran:
# Cancelled: tv left the cursor on the line below the prompt.
with open("/dev/tty", "w") as tty:
tty.write("\033[A")
def launch():
top = git("rev-parse", "--show-toplevel")
if not top:
new_session()
sessions = repo_sessions(top)
branch = git("branch", "--show-current")
if not branch:
pick(sessions)
return
timeline = Timeline(branch)
mine = next((s for s in sessions if timeline.branch_at(s.mtime) == branch), None)
if branch == default_branch():
if mine and time.time() - mine.mtime < RECENT:
cmd_resume(mine.path)
pick(sessions)
elif mine:
cmd_resume(mine.path)
else:
new_session()
def main(argv):
if not argv:
launch()
elif argv[0] == "--list":
cmd_list()
elif argv[0] == "--preview" and len(argv) == 2:
cmd_preview(argv[1])
elif argv[0] == "--resume" and len(argv) == 2:
cmd_resume(argv[1])
elif argv == ["--new"]:
new_session()
else:
sys.exit("usage: omp-session [--list | --preview FILE | --resume FILE | --new]")
if __name__ == "__main__":
main(sys.argv[1:])
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env bash
# pj — task runner across every task source in the project, picked with the
# television `pj` cable (~/.config/television/cable/pj.toml).
#
# Sources: package.json scripts, Makefile targets, justfile recipes, mise
# tasks, devbox scripts, and pyproject.toml task tables (poe, pdm, rye,
# taskipy, hatch envs, console scripts).
#
# Scanning: cwd upward to the git toplevel (inclusive); outside a repo it
# stops at the nearest directory that holds a task source. A source is only
# listed when its runner is actually installed, so the list is never dead.
# Tasks from the most recently modified manifest come first.
#
# Usage: pj [extra args…] extra args are forwarded to the picked task
#
# Env:
# PJ_QUIET=1 suppress the "no task sources" error — the down-arrow
# bindings use this, where an empty tree is the normal case.
# PJ_STATE=FILE key bindings pass a path; pj writes `open` to it when the
# picker opens and `pj --run` writes `ran`, so the binding
# can tell "no tasks" / "cancelled" / "task printed output"
# apart and redraw the prompt accordingly (tv --inline).
#
# Cable entry points (tab-separated record fields, see `--list`):
# pj --list print one record per task
# pj --preview KIND TARGET ROOT FILE render a task's body
# pj --run KIND TARGET ROOT RUNNER run a task ($PJ_EXTRA holds extra args)
have() { command -v "$1" >/dev/null 2>&1; }
# ── pyproject.toml reader ──────────────────────────────────────────
# `list FILE` prints "tool<TAB>target" per task; `show FILE TARGET TOOL`
# prints one task's body. tomllib is stdlib on python 3.11+.
py_toml() {
python3 - "$@" <<'PY' 2>/dev/null
import json, sys, tomllib
mode, path = sys.argv[1], sys.argv[2]
try:
with open(path, "rb") as fh:
data = tomllib.load(fh)
except Exception:
sys.exit(0)
def table(obj, *keys):
for key in keys:
if not isinstance(obj, dict):
return {}
obj = obj.get(key) or {}
return obj if isinstance(obj, dict) else {}
def tasks():
"""(tool, target, body) for every supported pyproject task table."""
out = []
for tool, tbl in (
("poe", table(data, "tool", "poe", "tasks")),
("pdm", table(data, "tool", "pdm", "scripts")),
("rye", table(data, "tool", "rye", "scripts")),
("taskipy", table(data, "tool", "taskipy", "tasks")),
):
for name, body in tbl.items():
if tool == "pdm" and (name.startswith("_") or name.startswith("post_")):
continue # pdm's shared/hook keys are not runnable targets
out.append((tool, name, body))
for env, cfg in table(data, "tool", "hatch", "envs").items():
for name, body in table(cfg, "scripts").items():
out.append(("hatch", f"{env}:{name}", body))
seen = set()
for tbl in (table(data, "tool", "poetry", "scripts"), table(data, "project", "scripts")):
for name, body in tbl.items():
if name in seen:
continue
seen.add(name)
out.append(("script", name, body))
return out
def render(body):
if isinstance(body, str):
return body
if isinstance(body, list):
return "\n".join(x if isinstance(x, str) else json.dumps(x) for x in body)
if isinstance(body, dict):
for key in ("cmd", "shell", "script", "expr", "ref"):
if isinstance(body.get(key), str):
return body[key]
if isinstance(body.get("sequence"), list):
return render(body["sequence"])
return json.dumps(body, indent=2)
return str(body)
if mode == "list":
for tool, name, _ in tasks():
print(f"{tool}\t{name}")
else:
target, tool = sys.argv[3], sys.argv[4]
for t, name, body in tasks():
if name == target and t == tool:
print(render(body))
break
PY
}
# ── Preview ────────────────────────────────────────────────────────
if [ "${1-}" = --preview ]; then
kind=${2-} target=${3-} root=${4-} file=${5-}
cd "$root" 2>/dev/null || exit 0
case $kind in
npm)
jq -r --arg s "$target" '.scripts[$s] // "(no such script)"' "$file"
;;
make)
awk -v t="$target" '
!hit && /^[^\t]/ {
ci = index($0, ":")
if (ci > 0) {
rest = substr($0, ci)
if (rest !~ /^::?=/) {
n = split(substr($0, 1, ci - 1), p, /[ \t]+/)
for (i = 1; i <= n; i++)
if (p[i] == t) { hit = 1; print; next }
}
}
}
hit && (/^\t/ || /^ +[^ ]/) { print; next }
hit { exit }
END { if (!hit) print "(no recipe — declared target only)" }
' "$file"
;;
just)
just --justfile "$file" --working-directory "$root" --show "$target" 2>&1
;;
mise)
mise tasks info "$target" 2>&1
;;
devbox)
jq -r --arg s "$target" \
'.shell.scripts[$s] | if type == "array" then join("\n") else (. // "(no such script)") end' \
"$file"
;;
*)
py_toml show "$file" "$target" "$kind"
;;
esac
exit 0
fi
# ── Run (the cable's enter action) ────────────────────────────────
if [ "${1-}" = --run ]; then
kind=${2-} target=${3-} root=${4-} runner=${5-}
cd "$root" || exit 1
# Tell the key binding a task ran (its output now sits below the prompt).
[ -n "${PJ_STATE-}" ] && echo ran >"$PJ_STATE"
# Extra args from `pj a b …`, shell-quoted into $PJ_EXTRA by the picker.
extra=()
[ -n "${PJ_EXTRA-}" ] && eval "extra=($PJ_EXTRA)"
# Runners that need `--` to forward extra args to the task itself.
if [ ${#extra[@]} -gt 0 ]; then
case "$kind:$runner" in
npm:'npm run' | mise:* | devbox:*) extra=(-- "${extra[@]}") ;;
esac
fi
# shellcheck disable=SC2206 # word splitting is how the runner prefix is built
cmd=(${runner} "$target" "${extra[@]}")
# Wrap long-running server tasks in openlogs (live log tail for agents).
# Matches dev/start/serve/watch and namespaced variants like dev:web.
wrapper=""
if [[ $target =~ ^(dev|start|serve|watch)(:.*)?$ ]]; then
# Resolve the binary: PATH → mise resolution → mise installs glob
ol=$(command -v openlogs || true)
if [ -z "$ol" ] && have mise; then
ol=$(mise which openlogs 2>/dev/null || true)
fi
if [ -z "$ol" ]; then
for cand in "$HOME"/.local/share/mise/installs/node/*/bin/openlogs; do
[ -x "$cand" ] && { ol=$cand; break; }
done
fi
if [ -n "$ol" ] && [ -x "$ol" ]; then
wrapper=$ol
else
echo "pj: openlogs not found, running unwrapped" >&2
fi
fi
if [ -n "$wrapper" ]; then
echo "→ $wrapper ${cmd[*]}" >&2
exec "$wrapper" "${cmd[@]}"
else
echo "→ ${cmd[*]}" >&2
exec "${cmd[@]}"
fi
fi
# ── Collect targets ────────────────────────────────────────────────
mise_config() {
local d=$1 c
for c in mise.toml .mise.toml mise/config.toml .mise/config.toml .config/mise/config.toml; do
[ -f "$d/$c" ] && { echo "$d/$c"; return 0; }
done
for c in mise-tasks .mise/tasks mise/tasks; do
[ -d "$d/$c" ] && { echo "$d/$c"; return 0; }
done
return 1
}
# Record layout (tab separated): kind, target, root, manifest, runner.
recs=()
add() { recs+=("$1"$'\t'"$2"$'\t'"$3"$'\t'"$4"$'\t'"$5"); }
collect_npm() {
local dir=$1 pm d s
local pkg=$dir/package.json
[ -f "$pkg" ] || return
# Priority: packageManager field (corepack) → lockfiles (walking up past
# $dir to catch monorepo-root locks) → npm fallback
pm=$(jq -r '.packageManager // empty' "$pkg" 2>/dev/null)
pm=${pm%%@*}
if [ -z "$pm" ]; then
d=$dir
while [ -z "$pm" ]; do
if [ -f "$d/bun.lockb" ] || [ -f "$d/bun.lock" ]; then
pm=bun
elif [ -f "$d/pnpm-lock.yaml" ]; then
pm=pnpm
elif [ -f "$d/yarn.lock" ]; then
pm=yarn
elif [ -f "$d/package-lock.json" ]; then
pm=npm
fi
[ "$d" = / ] && break
d=$(dirname "$d")
done
[ -n "$pm" ] || pm=npm
fi
have "$pm" || pm=npm
have "$pm" || return
while IFS= read -r s; do
[ -n "$s" ] && add npm "$s" "$dir" "$pkg" "$pm run"
done < <(jq -r '.scripts // {} | keys[]' "$pkg" 2>/dev/null)
}
collect_make() {
local dir=$1 f="" c t
have make || return
for c in GNUmakefile makefile Makefile; do
[ -f "$dir/$c" ] && { f=$dir/$c; break; }
done
[ -n "$f" ] || return
# Text parse: explicit rules plus .PHONY operands (targets built from
# variables only show up in .PHONY). `make -p` is not used — it expands
# $(shell ...) and would run code just to list names.
local -A seen=()
while IFS= read -r t; do
[ -n "$t" ] && [ -z "${seen[$t]-}" ] || continue
seen[$t]=1
add make "$t" "$dir" "$f" make
done < <(awk '
/^\t/ { next }
/^[ \t]*#/ { next }
{
line = $0
sub(/#.*/, "", line)
ci = index(line, ":")
if (ci == 0) next
lhs = substr(line, 1, ci - 1)
rest = substr(line, ci + 1)
if (substr(rest, 1, 1) == "=") next # := assignment
if (lhs ~ /[$%=]/) next # variables, pattern rules
n = split(lhs, parts, /[ \t]+/)
if (parts[1] == ".PHONY") {
sub(/^:+/, "", rest)
n = split(rest, parts, /[ \t]+/)
for (i = 1; i <= n; i++)
if (parts[i] != "" && parts[i] !~ /[$%]/) print parts[i]
next
}
for (i = 1; i <= n; i++)
if (parts[i] != "" && parts[i] !~ /^\./) print parts[i]
}
' "$f")
}
collect_just() {
local dir=$1 f="" c t
have just || return
for c in justfile .justfile Justfile JUSTFILE; do
[ -f "$dir/$c" ] && { f=$dir/$c; break; }
done
[ -n "$f" ] || return
for t in $(just --justfile "$f" --working-directory "$dir" --summary --unsorted 2>/dev/null); do
add just "$t" "$dir" "$f" just
done
}
collect_mise() {
local dir=$1 cfg t
have mise || return
cfg=$(mise_config "$dir") || return
# mise also inherits parent-directory configs; filter to tasks defined
# under $dir so parents are not duplicated as we walk up.
while IFS= read -r t; do
[ -n "$t" ] && add mise "$t" "$dir" "$cfg" "mise run"
done < <(mise -C "$dir" tasks ls --json 2>/dev/null |
jq -r --arg dir "$dir/" '
.[]
| select((.hide // false) | not)
| select((.source // "") | startswith($dir))
| .name' 2>/dev/null)
}
collect_devbox() {
local dir=$1 s
local f=$dir/devbox.json
[ -f "$f" ] || return
have devbox || return
while IFS= read -r s; do
[ -n "$s" ] && add devbox "$s" "$dir" "$f" "devbox run"
done < <(jq -r '.shell.scripts // {} | keys[]' "$f" 2>/dev/null)
}
# Runner prefix for a pyproject task table, or empty when nothing can run it.
# Tools that live in the project venv (poe, taskipy) fall back to `uv run`.
py_runner() {
case $1 in
poe) have poe && echo poe || { have uv && echo "uv run poe"; } ;;
taskipy) have task && echo task || { have uv && echo "uv run task"; } ;;
pdm) have pdm && echo "pdm run" ;;
rye) have rye && echo "rye run" ;;
hatch) have hatch && echo "hatch run" ;;
script)
if have poetry && grep -q '^\[tool\.poetry' "$2"; then
echo "poetry run"
elif have uv; then
echo "uv run"
fi
;;
esac
}
collect_py() {
local dir=$1 tool target runner
local f=$dir/pyproject.toml
[ -f "$f" ] || return
have python3 || return
local -A runners=()
while IFS=$'\t' read -r tool target; do
[ -n "$tool" ] && [ -n "$target" ] || continue
if [ -z "${runners[$tool]-}" ]; then
runners[$tool]=$(py_runner "$tool" "$f")
[ -n "${runners[$tool]}" ] || runners[$tool]="-"
fi
runner=${runners[$tool]}
[ "$runner" = - ] && continue
add "$tool" "$target" "$dir" "$f" "$runner"
done < <(py_toml list "$f")
}
# Walk cwd upward: to the git toplevel inside a repo, otherwise until a
# directory actually yields targets (a source file with no tasks in it must
# not hide a parent's Makefile).
git_top=$(git rev-parse --show-toplevel 2>/dev/null)
d=$PWD
while :; do
collect_npm "$d"
collect_make "$d"
collect_just "$d"
collect_mise "$d"
collect_devbox "$d"
collect_py "$d"
[ "$d" = "$git_top" ] && break
[ "$d" = / ] && break
[ -z "$git_top" ] && [ ${#recs[@]} -gt 0 ] && break
d=$(dirname "$d")
done
if [ ${#recs[@]} -eq 0 ]; then
[ -n "${PJ_QUIET-}" ] || echo "pj: no task sources found in this tree" >&2
exit 1
fi
# ── List ───────────────────────────────────────────────────────────
# Record (tab separated): display, kind, target, root, runner, manifest.
# Tag each display with its kind (npm rows use the package manager) so `make`,
# `just` or `poe` are typeable filters. The directory column only appears
# when more than one project contributed. The manifest is the last field so
# mtime-sort ranks tasks by their manifest's mtime.
primary=${recs[0]#*$'\t'}
primary=${primary#*$'\t'}
primary=${primary%%$'\t'*}
multi=0
for r in "${recs[@]}"; do
root=${r#*$'\t'}
root=${root#*$'\t'}
root=${root%%$'\t'*}
[ "$root" = "$primary" ] || { multi=1; break; }
done
list() {
local r kind target root file runner tag display
for r in "${recs[@]}"; do
IFS=$'\t' read -r kind target root file runner <<<"$r"
case $kind in
npm) tag=${runner%% *} ;; # npm | pnpm | yarn | bun
script) tag=py ;; # pyproject console entry points
*) tag=$kind ;;
esac
if [ "$multi" = 1 ]; then
display=$(printf '%-8s %-26s %s' "$tag" "$target" "${root##*/}")
else
display=$(printf '%-8s %s' "$tag" "$target")
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$display" "$kind" "$target" "$root" "$runner" "$file"
done | mtime-sort
}
if [ "${1-}" = --list ]; then
list
exit 0
fi
# ── Pick ───────────────────────────────────────────────────────────
# The scan already ran, so hand tv the result instead of letting the cable
# re-run `pj --list`; tv's enter action calls `pj --run`.
tmp=$(mktemp "${TMPDIR:-/tmp}/pj.XXXXXX") || exit 1
trap 'rm -f "$tmp"' EXIT
list >"$tmp"
if [ $# -gt 0 ]; then
PJ_EXTRA=$(printf '%q ' "$@")
export PJ_EXTRA
fi
[ -n "${PJ_STATE-}" ] && echo open >"$PJ_STATE"
tv pj --inline --source-command "cat '$tmp'" --input-header "$primary"
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
# pj-palette — pj's task runner as a tmux-palette source (the "tasks" palette,
# ~/.config/tmux-palette/palettes/tasks.json).
#
# pj-palette print palette items (JSON) for the active pane's
# project: running background tasks, quick actions
# (install, dev, sync, make, compose up, …) and
# every task `pj --list` finds
# pj-palette --run KIND TARGET ROOT RUNNER
# run a pj task (the item's action)
# pj-palette --cmd ROOT CMD…
# run a quick-action command in ROOT
# pj-palette --bg NAME [ROOT CMD…]
# show background session NAME in a popup, first
# starting CMD… in ROOT if it isn't running
# pj-palette --omp print the omp palette's items: this tmux
# session's omp (open or start), every other
# running <session>:omp, and Quit items
#
# One-off items open a full-width split along the bottom of the window (25% of
# its height) in the project root. The command runs in the foreground; ctrl-c
# stops it (the output stays). Once it has exited, enter or ctrl-c closes the
# pane.
#
# Long-running items (dev/start/serve/watch/preview tasks, compose up) run in a
# detached session named <session>:<tool-and-command> (leetcode:bun-run-dev),
# shown in a popup: q hides the popup and leaves the task running, ctrl-c
# asks for confirmation, then stops the task and ends its session. Running
# ones are listed first, under Running, to bring them back. tmux.conf's
# prefix + a starts omp the same way (<session>:omp); omp needs q, so its
# popup hides with ctrl-c (or prefix + a) and ctrl-q quits it.
set -uo pipefail
self=$(readlink -f "$0")
# While the task runs, ctrl-c reaches only the task: with SIGINT trapped,
# bash survives the child's death instead of exiting along with it.
interrupted=
trap 'interrupted=1' INT
finish() {
local status=$1 key
# A background task stopped with ctrl-c ends its session (and popup).
[ -n "${PJ_BG-}" ] && [ -n "$interrupted" ] && exit 0
printf '\n\033[2m[exit %s] enter or ctrl-c to close\033[0m' "$status"
trap 'exit 0' INT
while IFS= read -rsn1 key; do
[ -z "$key" ] && exit 0 # enter
done
exit 0
}
# Id of session NAME. Background session names contain ':', which -t reads
# as session:window, so they are targeted by id.
session_id() {
local id name
while IFS=$'\t' read -r id name; do
[ "$name" = "$1" ] && { printf '%s' "$id"; return 0; }
done < <(tmux list-sessions -F $'#{session_id}\t#{session_name}' 2>/dev/null)
return 1
}
case ${1-} in
--run)
shift
pj --run "$@"
finish $?
;;
--cmd)
cd "$2" || exit 1
shift 2
printf '→ %s\n' "$*" >&2
"$@"
finish $?
;;
--bg)
name=$2
if ! id=$(session_id "$name"); then
[ $# -ge 4 ] || exit 0 # a Running item whose task has since ended
root=$3
shift 3
id=$(tmux new-session -d -P -F '#{session_id}' -s "$name" -c "$root" -e PJ_BG=1 "$@") || exit 1
# @pj_task marks the session for the Running list (and for tmux.conf's
# prefix + a, which hides any of these popups).
tmux set -t "$id" @pj_task "$root" \; set -t "$id" status off \; \
set -t "$id" detach-on-destroy on
# Each session gets a key table for its hide/quit keys; other keys
# reach the program. A task's output takes no input, so q hides and
# ctrl-c (after a y/n confirm) stops the task. Interactive programs
# (omp) need q and typing, so ctrl-c hides and ctrl-q kills the session.
if [ "$1" = "$self" ]; then
tmux set -t "$id" key-table pj-task \; bind -T pj-task q detach-client \; \
bind -T pj-task C-c confirm-before -p 'Stop #{session_name}? (y/n)' 'send-keys C-c'
else
tmux set -t "$id" key-table pj-app \; bind -T pj-app C-c detach-client \; \
bind -T pj-app C-q kill-session
fi
fi
# Task output gets a big centered popup; interactive programs (omp) dock
# bottom-right beside the work. Positions past the client edge clamp to
# it (-x R would follow whichever pane tmux picks as current).
geometry=(-w 90% -h 80%)
[ "$(tmux display -p -t "$id" '#{key-table}')" = pj-app ] &&
geometry=(-w 35% -h 60% -x '#{client_width}' -y '#{client_height}')
tmux display-popup -E "${geometry[@]}" -b rounded -T " $name " "tmux attach -t '$id'"
exit 0
;;
esac
have() { command -v "$1" >/dev/null 2>&1; }
cwd=$(tmux display -p '#{pane_current_path}' 2>/dev/null) || cwd=$PWD
session=$(tmux display -p '#{session_name}' 2>/dev/null)
cd "$cwd" || exit 1
root=$(git rev-parse --show-toplevel 2>/dev/null) || root=$cwd
icon_for() {
case $1 in
bun) printf '' ;;
npm | yarn) printf '' ;;
pnpm) printf '' ;;
make) printf '' ;;
go) printf '' ;;
cargo) printf '' ;;
uv | py | poe | pdm | rye | taskipy | hatch | script) printf '' ;;
*) printf '' ;;
esac
}
# Tasks that run until stopped: dev/start/serve/watch/preview, also as a
# namespace segment (dev:web, api:dev).
long_running() { [[ $1 =~ (^|:)(dev|start|serve|watch|preview)(:|$) ]]; }
slug() { local s=${*,,}; s=${s//[^a-z0-9]/-}; tr -s - <<<"$s"; }
# Item action (a bash line the palette evals) running `pj-palette ARGV…`:
# in a full-width bottom split (25%) in DIR, or, given NAME, in the background
# session <session>:NAME shown in a popup. Argv goes to tmux as is (no shell,
# so fish re-parses nothing).
task_action() { # dir name argv…
local dir=$1 name=$2
shift 2
if [ -n "$name" ]; then
printf '%q ' "$self" --bg "$session:$name" "$dir" "$self" "$@"
else
printf '%q ' tmux split-window -v -f -l 25% -c "$dir" "$self" "$@"
fi
}
items=()
item() { # category icon title description aliases(csv) action [icon color]
items+=("$(jq -cn --arg c "$1" --arg i "$2" --arg t "$3" --arg d "$4" --arg a "$5" --arg x "$6" --arg k "${7-}" \
'{category: $c, icon: $i, title: $t, description: $d,
aliases: ($a | split(",") | map(select(. != ""))), action: {shell: $x}}
+ if $k == "" then {} else {iconColor: $k} end')")
}
quick() { # icon title aliases background(0/1) dir argv…
local icon=$1 title=$2 aliases=$3 dir=$5 name="" desc="in ${5/#$HOME/\~}"
[ "$4" = 1 ] && { name=$(slug "$title"); desc+=" · background"; }
shift 5
item Quick "$icon" "$title" "$desc" "$aliases" "$(task_action "$dir" "$name" --cmd "$dir" "$@")"
}
# ── omp palette (palettes/omp.json): the <session>:omp agent sessions ──────
if [ "${1-}" = --omp ]; then
here=$session:omp live='#5eff6c' idle='#7b8496'
omps=$(tmux list-sessions -F $'#{session_id}\t#{session_name}\t#{@pj_task}\t#{session_attached}' 2>/dev/null |
awk -F'\t' '$2 ~ /:omp$/ && $3 != ""')
state() { [ "$1" -gt 0 ] && printf open || printf hidden; }
found=
while IFS=$'\t' read -r id name dir attached; do
[ "$name" = "$here" ] || continue
found=1
item "This session" '' "$name" "$(state "$attached") · in ${dir/#$HOME/\~}" "" \
"$(printf '%q ' "$self" --bg "$name")" "$live"
done <<<"$omps"
[ -n "$found" ] || item "This session" '' "$here" "not running · start in ${cwd/#$HOME/\~}" "new" \
"$(printf '%q ' "$self" --bg "$here" "$cwd" omp)" "$idle"
while IFS=$'\t' read -r id name dir attached; do
[ -n "$name" ] && [ "$name" != "$here" ] || continue
item Running '' "$name" "$(state "$attached") · in ${dir/#$HOME/\~}" "" \
"$(printf '%q ' "$self" --bg "$name")" "$live"
done <<<"$omps"
while IFS=$'\t' read -r id name dir attached; do
[ -n "$name" ] && item Quit '' "Quit $name" "kill the session" "kill" \
"$(printf '%q ' tmux kill-session -t "$id")"
done <<<"$omps"
printf '%s\n' "${items[@]}" | jq -s .
exit 0
fi
# ── Background tasks still running (any project), to bring back ──────────
while IFS=$'\t' read -r name dir; do
[ -n "$dir" ] && item Running '' "$name" "in ${dir/#$HOME/\~}" "" "$(printf '%q ' "$self" --bg "$name")"
done < <(tmux list-sessions -F $'#{session_name}\t#{@pj_task}' 2>/dev/null)
# ── Quick actions, from the files at the project root ─────────────────────
if [ -f "$root/package.json" ]; then
pm=$(jq -r '.packageManager // empty' "$root/package.json" 2>/dev/null)
pm=${pm%%@*}
if [ -z "$pm" ]; then
if [ -f "$root/bun.lock" ] || [ -f "$root/bun.lockb" ]; then pm=bun
elif [ -f "$root/pnpm-lock.yaml" ]; then pm=pnpm
elif [ -f "$root/yarn.lock" ]; then pm=yarn
else pm=npm
fi
fi
if have "$pm"; then
quick "$(icon_for "$pm")" "$pm install" "install,i" 0 "$root" "$pm" install
jq -e '.scripts.dev' "$root/package.json" >/dev/null 2>&1 &&
quick "$(icon_for "$pm")" "$pm run dev" "dev" 1 "$root" "$pm" run dev
fi
fi
if [ -f "$root/pyproject.toml" ] && have uv; then
quick "$(icon_for uv)" "uv sync" "sync,install" 0 "$root" uv sync
quick "$(icon_for uv)" "uv run python" "py,repl" 0 "$root" uv run python
fi
if have make && { [ -f "$root/Makefile" ] || [ -f "$root/makefile" ] || [ -f "$root/GNUmakefile" ]; }; then
quick "$(icon_for make)" "make" "m,build" 0 "$root" make
fi
[ -f "$root/go.mod" ] && have go && quick "$(icon_for go)" "go run ." "run" 0 "$root" go run .
[ -f "$root/Cargo.toml" ] && have cargo && quick "$(icon_for cargo)" "cargo run" "run" 0 "$root" cargo run
if have docker; then
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
[ -f "$root/$f" ] || continue
quick '󰡨' "docker compose up" "up,compose" 1 "$root" docker compose up
break
done
fi
# ── Every task pj finds (npm/bun/pnpm scripts, make, just, mise, devbox,
# pyproject task tables and console scripts) ─────────────────────────────
while IFS=$'\t' read -r display kind target troot runner _file; do
[ -n "$kind" ] || continue
tag=${display%% *}
desc="$runner $target" name=""
[ "$troot" != "$root" ] && desc+=" (${troot#"$root"/})"
long_running "$target" && { name=$(slug "$runner $target"); desc+=" · background"; }
item "Tasks: $tag" "$(icon_for "$tag")" "$target" "$desc" "" \
"$(task_action "$troot" "$name" --run "$kind" "$target" "$troot" "$runner")"
done < <(PJ_QUIET=1 pj --list 2>/dev/null)
if [ ${#items[@]} -eq 0 ]; then
item Tasks '' "No tasks found" "in ${cwd/#$HOME/\~}" "" "tmux display-message 'pj: no task sources here'"
fi
printf '%s\n' "${items[@]}" | jq -s .
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Mirror every repo under the ghq root (~/Code/<host>/<owner>/<repo>) as a
# symlink ~/Projects/<repo>. Repos sharing a name across owners get
# <owner>-<repo>. Links into ~/Code whose repo is gone are removed; anything
# in ~/Projects that is not such a link is never touched. Run by
# projects-sync.timer (systemd user).
set -euo pipefail
code=${GHQ_ROOT:-$HOME/Code}
projects=$HOME/Projects
mkdir -p "$projects"
mapfile -t repos < <(find "$code" -mindepth 4 -maxdepth 4 -name .git -printf '%h\n' 2>/dev/null | sort)
declare -A count keep
for repo in "${repos[@]}"; do
name=${repo##*/}
count[$name]=$((${count[$name]:-0} + 1))
done
for repo in "${repos[@]}"; do
name=${repo##*/}
if ((count[$name] > 1)); then
owner=${repo%/*}
name=${owner##*/}-$name
fi
link=$projects/$name
keep[$name]=1
[[ -e $link && ! -L $link ]] && continue
[[ $(readlink "$link") == "$repo" ]] || ln -sfn "$repo" "$link"
done
for link in "$projects"/*; do
[[ -L $link && $(readlink "$link") == "$code"/* && -z ${keep[${link##*/}]:-} ]] && rm "$link"
done
exit 0
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# tmux-sessions — tmux session switcher as a tmux-palette source (the
# "sessions" palette, ~/.config/tmux-palette/palettes/sessions.json).
#
# tmux-sessions print palette items (JSON): the other sessions
# (last one first), every window of every session,
# zoxide's directories, ~/.config's directories,
# and a Rename and a Kill item per session
# tmux-sessions --new QUERY the palette's query item (what was typed): switch
# to session QUERY, or start it in QUERY if that is
# a directory, else in zoxide's best match for it
# tmux-sessions --dir DIR switch to, or start in DIR, the session named
# after DIR's basename without dots
#
# pj-palette's background task sessions (@pj_task) are left out. Sessions are
# targeted by id since names may contain ':'.
set -uo pipefail
self=$(readlink -f "$0")
session_id() { # name
local id name
while IFS=$'\t' read -r id name; do
[ "$name" = "$1" ] && { printf '%s' "$id"; return 0; }
done < <(tmux list-sessions -F $'#{session_id}\t#{session_name}' 2>/dev/null)
return 1
}
# Switch to session NAME, first starting it in DIR (when given) if missing.
open() { # name [dir]
local id
id=$(session_id "$1") ||
id=$(tmux new-session -d -P -F '#{session_id}' -s "$1" ${2:+-c "$2"}) || exit 1
tmux switch-client -t "$id"
}
case ${1-} in
--dir)
dir=${2%/}
name=${dir##*/}
open "${name//./}" "$2"
exit
;;
--new)
query=$2
[ -d "${query/#\~/$HOME}" ] && exec "$self" --dir "${query/#\~/$HOME}"
dir=
if ! session_id "$query" >/dev/null; then
read -ra words <<<"$query"
dir=$(zoxide query -- "${words[@]}" 2>/dev/null)
fi
open "$query" "$dir"
exit
;;
esac
{
tmux list-sessions -F $'S\t#{session_id}\t#{session_name}\t#{@pj_task}\t#{session_attached}\t#{session_windows}\t#{session_path}'
tmux list-windows -a -F $'W\t#{session_id}\t#{session_name}\t#{@pj_task}\t#{window_id}\t#{window_index}\t#{window_name}\t#{pane_current_path}'
zoxide query -l 2>/dev/null | sed 's/^/Z\t/'
find -L "$HOME/.config" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | sed 's/^/C\t/'
} | jq -Rn --arg self "$self" --arg home "$HOME" \
--arg cur "$(tmux display -p '#{session_id}')" --arg last "$(tmux display -p '#{client_last_session}')" '
def tilde: if startswith($home) then "~" + .[($home | length):] else . end;
def session_name: rtrimstr("/") | split("/") | last | gsub("\\."; "");
[inputs | split("\t")] as $rows
| ($rows | map(select(.[0] == "S" and .[3] == ""))) as $sessions
| ($rows | map(select(.[0] == "S") | .[2])) as $names
| ($rows | map(select(.[0] == "Z") | .[1])) as $zoxide
| ($sessions | map(select(.[1] != $cur))) as $others
| def dir($category; $icon):
(.[1] | session_name) as $name
| {category: $category, icon: $icon, title: (.[1] | tilde),
description: (if $names | any(. == $name) then "switch to \($name)" else "new session \($name)" end),
action: {shell: "\($self | @sh) --dir \(.[1] | @sh)"}};
[
(($others | map(select(.[2] == $last)) + map(select(.[2] != $last)))[]
| {category: "Sessions", icon: "󱂬", title: .[2],
description: "\(.[5]) window\(if .[5] == "1" then "" else "s" end)\(if .[4] != "0" then " · attached" else "" end) · \(.[6] | tilde)",
action: {tmux: "switch-client -t \(.[1] | @sh)"}}
+ (if .[4] != "0" then {iconColor: "#5eff6c"} else {} end)),
($rows[] | select(.[0] == "W" and .[3] == "")
| (.[6] | capture("^(?<icon>[^\\x00-\\x7f]) (?<title>.*)$") // {icon: "", title: .}) as $w
| {category: "Windows", icon: $w.icon, title: $w.title,
description: "\(.[2]):\(.[5]) · \(.[7] | tilde)",
action: {tmux: "switch-client -t \("\(.[1]):\(.[4])" | @sh)"}}),
($rows[] | select(.[0] == "Z") | dir("Zoxide"; "")),
($rows[] | select(.[0] == "C" and (.[1] as $d | $zoxide | any(. == $d) | not)) | dir("Config"; "󱃖")),
($sessions[]
| {category: "Rename", icon: "󰑕", title: "Rename \(.[2])", description: "rename the session",
action: {tmux: "command-prompt -I \(.[2] | @sh) -p \("Rename \(.[2]):" | @sh) \("rename-session -t \(.[1] | @sh) -- \"%%%\"" | @sh)"}}),
($others[]
| {category: "Kill", icon: "󱂧", title: "Kill \(.[2])", description: "kill the session",
action: {tmux: "kill-session -t \(.[1] | @sh)"}})
]'
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Yazi `edit` opener. Inside a Neovim terminal ($NVIM set) files open in the
# HOST instance via _G.EditFromYazi (see ~/.config/nvim/lua/config/yazi_session.lua)
# and the yazi window hides itself; standalone yazi gets a regular blocking nvim.
#
# usage: nvim-open.sh [vsplit|split|tabedit] file... (default mode: edit)
case "$1" in
vsplit | split | tabedit) mode="$1"; shift ;;
*) mode="edit" ;;
esac
[ -n "$YAZI_OPEN_TRACE" ] && printf 'NVIM=%s MODE=%s ARGS=%s\n' "$NVIM" "$mode" "$*" >> "$YAZI_OPEN_TRACE"
if [ -z "$NVIM" ]; then
# split modes are meaningless outside an nvim terminal — quiet no-op
[ "$mode" = "edit" ] || exit 0
exec nvim "$@"
fi
for f in "$@"; do
abs=$(realpath -- "$f") || continue
# escape for a vimscript double-quoted string
esc=$(printf '%s' "$abs" | sed 's/[\\"]/\\&/g')
nvim --server "$NVIM" --remote-expr "v:lua.EditFromYazi(\"$mode\", \"$esc\")"
done