Initial import: chezmoi source for CachyOS + Umbriel + Noctalia

Imported from prdlk/dotfiles master (efdd827). Drops the niri, Hyprland/Omarchy,
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 ee355bcfd5
152 files changed
+11564

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, env repos)
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;
' -- "$@"
+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)"}})
]'
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# umbriel-float-square
# Floating focused window: square, as tall as the usable height (below
# Noctalia's bar) minus PAD px above and below, centered on its output.
# Tiled: center its column (the old Mod+C). Extents are fractions of the
# usable area, so both sides are computed from the output's usable size.
set -eu
PAD=4
window=$(umbriel windows --json | jq -c '[.[] | select(.active)][0] // empty')
[ -n "$window" ] || exit 0
if [ "$(printf '%s' "$window" | jq -r .floating)" != true ]; then
exec umbriel msg column-center
fi
id=$(printf '%s' "$window" | jq -r .id)
output=$(printf '%s' "$window" | jq -r .output)
extents=$(umbriel outputs --json | jq -r --arg o "$output" --argjson pad "$PAD" '
def frac: . * 10000 | floor / 10000;
.[] | select(.name == $o) | .usable_area
| (.height - 2 * $pad) as $side
| "\($side) \([$side / .width, 1] | min | frac) \($side / .height | frac)"')
set -- $extents
side=$1
umbriel msg "window-set-secondary-extent:$3"
umbriel msg "window-set-primary-extent:$2"
# window-center uses the size the client has committed; centering before the
# resize lands centers the old width. Wait up to 1 s for the new width.
i=0
while [ $i -lt 20 ]; do
w=$(umbriel windows --json | jq -r --arg id "$id" '.[] | select(.id == $id) | .w')
[ $((w - side)) -ge -1 ] && [ $((w - side)) -le 1 ] && break
sleep 0.05
i=$((i + 1))
done
exec umbriel msg window-center
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# umbriel-launch PATTERN COMMAND...
# Focus the next workspace window whose app id matches PATTERN (a
# case-insensitive jq regex), cycling through matches on repeated presses;
# with none open, spawn COMMAND. Scratchpad windows are skipped: they have
# their own toggles. set -eu
[ $# -ge 2 ] || { echo "usage: umbriel-launch PATTERN COMMAND..." >&2; exit 2; }
pattern=$1
shift
# Matches in a stable order (window id); the one after the focused match, or
# the first when focus is elsewhere. `.active` is the one window with keyboard
# focus; `.focused` is set on every output's last-focused window.
target=$(umbriel windows --json | jq -r --arg p "$pattern" '
[.[] | select(.scratchpad == "" and (.app_id | test($p; "i")))] | sort_by(.id)
| if length == 0 then empty
else (map(.active) | index(true)) as $i
| if $i == null then .[0].id else .[($i + 1) % length].id end
end')
if [ -n "$target" ]; then
exec umbriel msg "window-focus:$target"
fi
exec umbriel msg spawn "$@"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# umbriel-smart-move left|right
# Consume-or-expel move, extended across outputs:
# - stacked in a column → expel to a new column on that side
# - alone, column beside it → consume into that column
# - alone, at the strip's end → move to the output on that side
# Pressing twice therefore steps a window past its neighbour: in, then out the
# far side. Dwindle and master workspaces have no columns; there the window
# moves in that direction, or onto the next output at the edge. Floating and
# scratchpad windows are left alone.
set -eu
case ${1:-} in
left | right) dir=$1 ;;
*) echo "usage: umbriel-smart-move left|right" >&2; exit 2 ;;
esac
windows=$(umbriel windows --json)
layout=$(umbriel workspaces --json | jq -r '.[] | select(.focused) | .layout')
# "<action>" for the focused window, or nothing when it should stay put.
action=$(printf '%s' "$windows" | jq -r --arg dir "$dir" --arg layout "$layout" '
(map(select(.focused)) | first) as $w
| if $w == null or $w.floating or $w.scratchpad != "" then empty
elif $layout != "scrolling" then "window-move-or-output-\($dir)"
else
[ .[] | select(.workspace == $w.workspace and (.floating | not) and .scratchpad == "") ] as $tiled
| ([ $tiled[] | select(.column == $w.column) ] | length) as $rows
| ([ $tiled[] | .column ] | if $dir == "left" then min else max end) as $edge
| if $rows > 1 or $w.column != $edge then "window-consume-or-expel-\($dir)"
else "window-move-or-output-\($dir)"
end
end')
[ -n "$action" ] || exit 0
# At the outermost output, "no output to the left/right" is the expected end.
umbriel msg "$action" >/dev/null 2>&1 || true
+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