From 4f9c36cb7972ba282dd35c621b77ddb15b046c61 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Sat, 29 Aug 2026 17:40:48 -0400 Subject: [PATCH] feat(cli): add scratchpad toggle and exclude scratch terminal from cycle --- dot_local/bin/executable_omarchy-cycle | 15 +- .../bin/executable_omarchy-toggle-scratch | 45 ++ dot_local/bin/executable_pj | 446 +++++++++++++++--- 3 files changed, 448 insertions(+), 58 deletions(-) create mode 100755 dot_local/bin/executable_omarchy-toggle-scratch diff --git a/dot_local/bin/executable_omarchy-cycle b/dot_local/bin/executable_omarchy-cycle index 415c171..750640e 100755 --- a/dot_local/bin/executable_omarchy-cycle +++ b/dot_local/bin/executable_omarchy-cycle @@ -22,12 +22,16 @@ declare -A KITTY_CMDS=( # --- Hyprland helpers -------------------------------------------------------- # Print the addresses of all windows whose class or initialClass matches the -# regex in $1. Windows are sorted by workspace, then by address, so the cycle -# order stays the same between calls. +# 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" ' + 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' @@ -125,8 +129,9 @@ elif [[ $APP == "brave" ]]; then DEFAULT_SPAWN="brave-origin-nightly" elif [[ $APP == "terminal" ]]; then - # Cycle all terminals. - INSTANCES=($(addresses_matching '(kitty|alacritty|ghostty|foot|wezterm)')) + # Cycle all terminals. kitty-herdr is the SUPER + A scratch terminal + # (omarchy-toggle-scratch owns it), so it stays out of the rotation. + INSTANCES=($(addresses_matching '(kitty|alacritty|ghostty|foot|wezterm)' '^kitty-herdr$')) DEFAULT_LAUNCHER="omarchy-launch-terminal" elif [[ $APP == "ai" ]]; then diff --git a/dot_local/bin/executable_omarchy-toggle-scratch b/dot_local/bin/executable_omarchy-toggle-scratch new file mode 100755 index 0000000..05da444 --- /dev/null +++ b/dot_local/bin/executable_omarchy-toggle-scratch @@ -0,0 +1,45 @@ +#!/bin/bash + +# omarchy:summary=Toggle the scratchpad, starting the kitty/herdr scratch terminal when it holds no windows. + +set -uo pipefail + +NAME="scratchpad" +WORKSPACE="special:$NAME" +CLASS="kitty-herdr" +LAUNCH="kitty --class=$CLASS --config $HOME/.config/kitty/herdr.conf" + +# Windows living on the scratchpad, whether it is shown or hidden. +scratch_windows() { + hyprctl clients -j | jq --arg ws "$WORKSPACE" '[.[] | select(.workspace.name == $ws)] | length' +} + +# True when some monitor currently displays the scratchpad. +scratch_visible() { + hyprctl monitors -j | jq -e --arg ws "$WORKSPACE" 'any(.[]; .specialWorkspace.name == $ws)' >/dev/null +} + +# Hyprland's lua config takes lua expressions on `hyprctl dispatch`; the +# classic dispatcher is the pre-lua fallback (same shape as omarchy-cycle). +toggle() { + hyprctl dispatch "hl.dsp.workspace.toggle_special(\"$NAME\")" >/dev/null 2>&1 || + hyprctl dispatch togglespecialworkspace "$NAME" >/dev/null +} + +if (($(scratch_windows) > 0)); then + toggle + exit 0 +fi + +# Empty scratchpad: there is nothing to toggle into view, so start the herdr +# terminal instead. hypr/hyprland.lua pins kitty-herdr to the scratchpad, so +# only the reveal is left here — and deciding it after the window lands keeps +# this correct whether or not the rule already showed the workspace. +setsid uwsm-app -- $LAUNCH >/dev/null 2>&1 & + +for _ in {1..40}; do + sleep 0.1 + (($(scratch_windows) > 0)) && break +done + +scratch_visible || toggle diff --git a/dot_local/bin/executable_pj b/dot_local/bin/executable_pj index 5269c8f..c47e76f 100755 --- a/dot_local/bin/executable_pj +++ b/dot_local/bin/executable_pj @@ -1,71 +1,411 @@ #!/usr/bin/env bash -# fzf package.json script runner with auto-detected package manager. +# pj — fzf task runner across every task source in the project. +# +# 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. +# +# Env: +# PJ_QUIET=1 suppress the "no task sources" error — the fish down-arrow +# binding uses this, where an empty tree is the normal case. +# +# Internal: `pj --preview KIND TARGET ROOT FILE` renders the fzf preview. -# ── Locate nearest package.json (walk up) ────────────────────────── -dir=$PWD -pkg="" -while :; do - if [ -f "$dir/package.json" ]; then - pkg="$dir/package.json" - break +have() { command -v "$1" >/dev/null 2>&1; } + +# Resolve this script's path so the fzf preview can re-enter it. +self=$0 +case $self in + */*) ;; + *) self=$(command -v -- "$self" 2>/dev/null || echo "$self") ;; +esac + +# ── pyproject.toml reader ────────────────────────────────────────── +# `list FILE` prints "tooltarget" 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 +} + +# ── fzf 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 + +# ── 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 - [ "$dir" = / ] && break - dir=$(dirname "$dir") + 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 [ -z "$pkg" ]; then - echo "pj: no package.json found in this tree" >&2 +if [ ${#recs[@]} -eq 0 ]; then + [ -n "${PJ_QUIET-}" ] || echo "pj: no task sources found in this tree" >&2 exit 1 fi -root=$(dirname "$pkg") -# ── Detect package manager ───────────────────────────────────────── -# Priority: packageManager field (corepack) → lockfiles (walking up -# past $root to catch monorepo-root locks) → npm fallback -pm=$(jq -r '.packageManager // empty' "$pkg") -pm=${pm%%@*} +# ── Pick a target ────────────────────────────────────────────────── +# Tag each row with its kind (npm rows use the package manager) so `make`, +# `just` or `poe` are typeable filters in fzf. +# The directory column only appears when more than one project contributed. +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 -if [ -z "$pm" ]; then - d=$root - 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 +lines=() +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 \033[2m%s\033[0m' "$tag" "$target" "${root##*/}") + else + display=$(printf '%-8s %s' "$tag" "$target") + fi + lines+=("$display"$'\t'"$r") +done -# ── Pick a script ────────────────────────────────────────────────── -script=$(jq -r '.scripts // {} | keys[]' "$pkg" | fzf \ - --prompt="$pm run ❯ " \ - --header="$root" \ +selection=$(printf '%s\n' "${lines[@]}" | fzf \ + --ansi \ + --delimiter='\t' --with-nth=1 \ + --prompt='run ❯ ' \ + --header="$primary" \ --height=40% --layout=reverse --border \ - --preview="jq -r --arg s {} '.scripts[\$s]' \"$pkg\"" \ - --preview-window=up:2:wrap) + --preview="'$self' --preview {2} {3} {4} {5}" \ + --preview-window=up:6:wrap) -[ -n "$script" ] || exit 0 +[ -n "$selection" ] || exit 0 -# ── Run (npm needs `--` to forward extra args) ──────────────────── +IFS=$'\t' read -r _display kind target root _file runner <<<"$selection" + +# ── Run ──────────────────────────────────────────────────────────── +cd "$root" || exit 1 + +# Runners that need `--` to forward extra args to the task itself. extra=("$@") -if [ "$pm" = npm ] && [ $# -gt 0 ]; then - extra=(-- "$@") +if [ $# -gt 0 ]; then + case "$kind:$runner" in + npm:'npm run' | mise:* | devbox:*) extra=(-- "$@") ;; + esac fi -# Wrap long-running server scripts in openlogs (live log tail for agents). -# Matches dev/start/serve and namespaced variants like dev:web, start:api. +# 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 [[ $script =~ ^(dev|start|serve|watch)(:.*)?$ ]]; then +if [[ $target =~ ^(dev|start|serve|watch)(:.*)?$ ]]; then # Resolve the binary: PATH → mise resolution → mise installs glob ol=$(command -v openlogs || true) - if [ -z "$ol" ] && command -v mise >/dev/null 2>&1; then + if [ -z "$ol" ] && have mise; then ol=$(mise which openlogs 2>/dev/null || true) fi if [ -z "$ol" ]; then @@ -82,9 +422,9 @@ if [[ $script =~ ^(dev|start|serve|watch)(:.*)?$ ]]; then fi if [ -n "$wrapper" ]; then - echo "→ $wrapper $pm run $script $*" >&2 - exec "$wrapper" "$pm" run "$script" "${extra[@]}" + echo "→ $wrapper ${cmd[*]}" >&2 + exec "$wrapper" "${cmd[@]}" else - echo "→ $pm run $script $*" >&2 - exec "$pm" run "$script" "${extra[@]}" + echo "→ ${cmd[*]}" >&2 + exec "${cmd[@]}" fi