From 7575ce2542308dbf188cdf9d48544cedad2c7f08 Mon Sep 17 00:00:00 2001 From: Samuel Mendenhall Date: Sat, 29 Aug 2026 15:21:44 -0400 Subject: [PATCH 1/3] Add workspace display substitutions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d3a35c7-0a5f-4e6c-9a09-567c989e068d --- CHANGELOG.md | 4 ++ README.md | 12 ++++- automatic-rename.sh | 90 ++++++++++++++++++++++++------- config.example.sh | 10 ++++ tests/test_ws_cwd.sh | 126 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de49ea8..f01e037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to herdr-automatic-rename are documented here. The format fo ## [Unreleased] +### Added + +- Directory-derived workspace labels can now be rewritten with ordered `WORKSPACE_SUBSTITUTE_SETS` rules. The rewrite changes only the name displayed by herdr and leaves the worktree directory unchanged. A hand-entered name that differs from the directory-derived name is not altered; herdr does not expose whether a matching name was entered by hand. + ### Fixed - A numbered workspace goes on following its directory ([#13](https://github.com/qu8n/herdr-automatic-rename/issues/13)). herdr names a workspace after `identity_cwd`, its own tracked directory, and the first `workspace rename` freezes that name for good: herdr keeps the directory current and never labels from it again. Numbering a workspace pinned it to whatever it was called when it opened, and no smaller rename would have helped, because there is no rename that leaves the derivation alive. diff --git a/README.md b/README.md index 5adb962..9f2415d 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,17 @@ cp "$(dirname "$(herdr plugin list --json | jq -r '.result.plugins[]|select(.plu ~/.config/herdr-automatic-rename/config.sh ``` -`HERDR_AUTOMATIC_RENAME_CONFIG` overrides that path. [config.example.sh](config.example.sh) documents every knob: numbering per row kind, agent titles, label length, the program lists (shells, ignored commands, custom labels), and Nerd Font icons. +`HERDR_AUTOMATIC_RENAME_CONFIG` overrides that path. [config.example.sh](config.example.sh) documents every knob: numbering per row kind, workspace display rewrites, agent titles, label length, the program lists (shells, ignored commands, custom labels), and Nerd Font icons. + +For example, this shortens a displayed workspace name from `worktree-feature` to `wt-feature` without renaming its directory or Git worktree: + +```bash +WORKSPACE_SUBSTITUTE_SETS=( + 's|^worktree-|wt-|' +) +``` + +A workspace name entered by hand is unchanged when it differs from the directory-derived name. Herdr does not expose whether a matching name was entered by hand. ## Actions diff --git a/automatic-rename.sh b/automatic-rename.sh index 2e653fd..60606a4 100755 --- a/automatic-rename.sh +++ b/automatic-rename.sh @@ -67,6 +67,10 @@ LOCK_DIR="$STATE_DIR/lock" RERUN_FLAG="$STATE_DIR/rerun" CONFIG_FILE="${HERDR_AUTOMATIC_RENAME_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/herdr-automatic-rename/config.sh}" +# Ordered `sed -E` rewrites for directory-derived workspace labels. Config loads +# later and may replace this empty default. +declare -p WORKSPACE_SUBSTITUTE_SETS >/dev/null 2>&1 || WORKSPACE_SUBSTITUTE_SETS=() + # `task` is the shape a TITLE arrives in: control characters gone, the leading run # of non-alphanumerics gone (an agent parks a spinner glyph there), the agent's own # brand gone with it, no trailing space, and inner runs of it collapsed. One @@ -493,15 +497,16 @@ ar_state_get() { # jq -r --arg t "$1" --arg f "$2" '.[$t][$f] as $v | if $v == null then empty else $v end' \ "$STATE_FILE" 2>/dev/null } -ar_state_set() { # - local base tmp +ar_state_set() { # [source-name] + local base tmp source=${4:-} filter=".[\$t] = {auto: \$a, enabled: \$e}" base=$(ar_state_read) || return 1 # unreadable: leave the file alone # A write that did not land reports it. Ownership IS this file, so swallowing a # full disk or an unwritable state directory told the reset action a tab was # re-adopted while the next pass, finding no entry, opted it straight back out. tmp=$(mktemp "$STATE_DIR/.state.XXXXXX") || return 1 - if printf '%s' "$base" | jq --arg t "$1" --arg a "$2" --argjson e "$3" \ - '.[$t] = {auto: $a, enabled: $e}' > "$tmp" 2>/dev/null; then + [ "$#" -lt 4 ] || filter=".[\$t] = {auto: \$a, enabled: \$e, source: \$s}" + if printf '%s' "$base" | jq --arg t "$1" --arg a "$2" --argjson e "$3" --arg s "$source" \ + "$filter" > "$tmp" 2>/dev/null; then mv "$tmp" "$STATE_FILE" || return 1 else rm -f "$tmp" @@ -939,6 +944,16 @@ ar_project_base() { printf '%s' "${dir##*/}" } +# ar_workspace_subst -> a directory-derived workspace label with each +# configured rewrite applied in order. +ar_workspace_subst() { + local s=$1 expr + for expr in "${WORKSPACE_SUBSTITUTE_SETS[@]}"; do + s=$(printf '%s' "$s" | sed -E "$expr") + done + printf '%s' "$s" +} + # ar_workspace_pane_dirs -> one "" # row per workspace, from the panes the pass already holds: its focused pane, or # a pane of the tab it has active, or any pane of it. @@ -1007,11 +1022,13 @@ ar_identity_base() { # # moved on and ours has not, and only the record tells that apart from a name # somebody typed. Anything else is somebody's name and is left alone for good. ar_ws_track_eligible() { - local key="ws:$1" slabel=$2 ibase=$3 enabled auto + local key="ws:$1" slabel=$2 ibase=$3 enabled auto source enabled=$(ar_state_get "$key" enabled) auto=$(ar_state_get "$key" auto) + source=$(ar_state_get "$key" source) AR_WS_STATE_ENABLED=$enabled AR_WS_STATE_AUTO=$auto + AR_WS_STATE_SOURCE=$source if [ "$slabel" = "$ibase" ]; then return 0 elif [ "$enabled" = "true" ] && [ "$slabel" = "$auto" ]; then @@ -1021,17 +1038,32 @@ ar_ws_track_eligible() { return 1 } -# ar_ws_claim - record that we own this workspace's base, +# ar_ws_claim - record that we own this workspace's base, # unless state already says exactly that (the steady state, every pass, for every # tracked workspace: ar_state_set rewrites the whole file). Reads what # ar_ws_track_eligible published for this same workspace. Only ever called for a # base the workspace CARRIES -- a base recorded for a rename that never landed # reads as a hand-typed name one pass later, and opts the workspace out. ar_ws_claim() { - if [ "${AR_WS_STATE_ENABLED:-}" = "true" ] && [ "${AR_WS_STATE_AUTO:-}" = "$2" ]; then + if [ "${AR_WS_STATE_ENABLED:-}" = "true" ] && [ "${AR_WS_STATE_AUTO:-}" = "$2" ] && + [ "${AR_WS_STATE_SOURCE:-}" = "$3" ]; then return 0 fi - ar_state_set "ws:$1" "$2" true + ar_state_set "ws:$1" "$2" true "$3" +} + +# ar_ws_subst_pending -> 0 when a prior substitution still differs from its +# directory-derived source and needs one pass after the rules are removed. +ar_ws_subst_pending() { + local base + base=$(ar_state_read) || return 1 + printf '%s' "$base" | jq -e ' + any(to_entries[]?; + (.key | startswith("ws:")) + and .value.enabled == true + and (.value.source | type) == "string" + and .value.auto != .value.source) + ' >/dev/null 2>&1 } # ar_state_prune_ws - drop the "ws:" records of @@ -1062,10 +1094,14 @@ ar_state_prune_ws() { # and --clear skips it entirely: the uninstall path strips prefixes and retitles # nothing. ar_renumber_workspaces() { - local json=$1 rows wid label pos base want ibase track seen="" + local json=$1 rows wid label pos base want ibase track prefix index_pass=0 seen="" [ -n "$json" ] || return 0 + ar_index_pass workspaces && index_pass=1 rows=$(ar_workspace_positions "$json" "$(ar_collapsed_spaces)") - [ -n "$rows" ] || return 0 + if [ -z "$rows" ]; then + [ "$CLEAR" = "1" ] || ar_state_prune_ws + return 0 + fi AR_WS_IDENTITY="" AR_WS_PANEDIR="" if [ "$CLEAR" != "1" ]; then @@ -1078,15 +1114,26 @@ ar_renumber_workspaces() { base=$(ar_strip_prefix "$label") track=0 if ibase=$(ar_identity_base "$wid") && ar_ws_track_eligible "$wid" "$base" "$ibase"; then - base=$ibase + base=$(ar_workspace_subst "$ibase") track=1 fi [ -n "$base" ] || continue # empty label: nothing to number, leave it - want=$(ar_desired workspaces "$pos" "$base") # position 0 (hidden) -> bare, like 10+ + if [ "$index_pass" = "1" ]; then + want=$(ar_desired workspaces "$pos" "$base") # position 0 (hidden) -> bare, like 10+ + elif [ "$track" = "1" ]; then + prefix=$(ar_index_prefix "$label") + want="${prefix}${base}" + else + want=$label + fi if [ "$want" != "$label" ]; then "$HERDR" workspace rename "$wid" "$want" >/dev/null 2>&1 || continue fi - [ "$track" = "1" ] && ar_ws_claim "$wid" "$base" + if [ "$track" = "1" ] && + { [ "$index_pass" = "1" ] || [ "$base" != "$ibase" ] || + [ "${AR_WS_STATE_ENABLED:-}" = "true" ]; }; then + ar_ws_claim "$wid" "$base" "$ibase" + fi done <<< "$rows" # A workspace id carries no whitespace (both go through `clean`), so the # space-joined list splits into one argument per workspace. @@ -1426,7 +1473,11 @@ ar_notify() { # whether to number or to strip; --clear ignores the toggles and strips # everything (the uninstall path). ar_reconcile() { - local wsjson snap + local wsjson snap workspace_pass=0 + if ar_index_pass workspaces || [ "${#WORKSPACE_SUBSTITUTE_SETS[@]}" -gt 0 ] || + ar_ws_subst_pending; then + workspace_pass=1 + fi # A reset deletes the target tab's state once (under the lock) so it re-adopts. # Whether there was anything to re-adopt is read BEFORE the delete, because that # is what the action reports back and `del` on a key that was never there @@ -1511,14 +1562,15 @@ ar_reconcile() { fi else wsjson=$("$HERDR" workspace list 2>/dev/null) || wsjson="" - if [ "$CLEAR" != "1" ] && [ "$NAME_TABS" = "1" ]; then + if [ "$CLEAR" != "1" ] && + { [ "$NAME_TABS" = "1" ] || [ "$workspace_pass" = "1" ]; }; then AR_PANES_JSON=$("$HERDR" pane list 2>/dev/null) || AR_PANES_JSON='{"result":{"panes":[]}}' fi fi - # ar_index_pass decides which of these have work to do (numbering, or the - # strip a named-and-off kind asks for). Tabs carry an extra arm because they - # are the only kind we NAME, so that pass runs whatever the numbering says. - if ar_index_pass workspaces; then + # ar_index_pass decides which kinds have numbering work to do, or a prefix to + # strip. Workspace substitutions and tab naming also run their pass without + # numbering. + if [ "$workspace_pass" = "1" ]; then ar_renumber_workspaces "$wsjson" fi if ar_index_pass tabs || [ "$NAME_TABS" = "1" ]; then diff --git a/config.example.sh b/config.example.sh index 65f0dcb..6178b9c 100644 --- a/config.example.sh +++ b/config.example.sh @@ -38,6 +38,16 @@ # Anything else in brackets ("[wip] foo") is left alone, digits are the only # trigger. +# Ordered `sed -E` rewrites for directory-derived workspace names. These change +# only the label shown by herdr; they do not rename the directory or Git +# worktree. A hand-entered name that differs from the derived directory name is +# left unchanged. Herdr exposes no way to distinguish a hand-entered name that +# exactly matches the derived name. For example, shorten "worktree-feature" to +# "wt-feature": +# WORKSPACE_SUBSTITUTE_SETS=( +# 's|^worktree-|wt-|' +# ) + # ---- naming knobs (only used when NAME_TABS=1) ---- # 1 = a regular program shows its full command line ("psql -h db"); 0 = just its diff --git a/tests/test_ws_cwd.sh b/tests/test_ws_cwd.sh index d8f34dd..6d9e27e 100644 --- a/tests/test_ws_cwd.sh +++ b/tests/test_ws_cwd.sh @@ -40,6 +40,11 @@ check "outside any repo" "sub" "$(ar_project_base "$PB/plain/sub")" check "trailing slash ignored" "sub" "$(ar_project_base "$PB/plain/sub/")" check "relative path: basename" "notes" "$(ar_project_base "some/notes")" check "empty path: empty base" "" "$(ar_project_base "")" + +WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|') +check "workspace display rewrite" "wt-feature" "$(ar_workspace_subst "worktree-feature")" +check "workspace rewrite nonmatch" "project-a" "$(ar_workspace_subst "project-a")" +WORKSPACE_SUBSTITUTE_SETS=() rm -rf "$PB" # Numbering only (NAME_TABS off, no tab/pane fixtures), so the rename log holds @@ -389,4 +394,125 @@ check_contains "identity_cwd wins" "$(log)" "workspace rename w1 [1] from-identi check_absent "pane dir ignored" "$(log)" "from-pane" teardown +# ====================================================================== +# Scenario 15: workspace substitutions change only the displayed, derived name +# and compose with numbering. +# ====================================================================== +setup +printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \ + >"$HERDR_AUTOMATIC_RENAME_CONFIG" +workspaces "$(ws w1 worktree-feature)" +session "w1=/home/u/worktree-feature" +run_event workspace.created +check_contains "numbered workspace display rewritten" "$(log)" "workspace rename w1 [1] wt-feature" + +clear_log +workspaces "$(ws w1 '[1] wt-feature')" +run_event pane.focused +check "rewritten workspace settles" "" "$(log)" +teardown + +# ====================================================================== +# Scenario 16: substitutions run without numbering when AUTO_INDEX is off. +# ====================================================================== +setup +export AUTO_INDEX=0 +printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \ + >"$HERDR_AUTOMATIC_RENAME_CONFIG" +workspaces "$(ws w1 worktree-feature)" +session "w1=/home/u/worktree-feature" +run_event workspace.created +check_contains "unnumbered workspace display rewritten" "$(log)" "workspace rename w1 wt-feature" +teardown + +# ====================================================================== +# Scenario 17: the list-command fallback derives a new workspace from its pane +# before session.json has persisted it. +# ====================================================================== +setup +export AUTO_INDEX=0 +printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \ + >"$HERDR_AUTOMATIC_RENAME_CONFIG" +mkdir -p "$SB/home/worktree-feature" +workspaces "$(ws w1 worktree-feature)" +session "w9=/home/other" +fixture panes.json <"$HERDR_AUTOMATIC_RENAME_CONFIG" +workspaces "$(ws w1 '[1] worktree-incident')" +session "w1=/home/u/project-a" +run_event workspace.created +check "manual workspace name not rewritten" "" "$(log)" +teardown + +# ====================================================================== +# Scenario 19: removing the substitution restores the derived workspace label +# even when global numbering is off and would not otherwise run this pass. +# ====================================================================== +setup +export AUTO_INDEX=0 +printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \ + >"$HERDR_AUTOMATIC_RENAME_CONFIG" +workspaces "$(ws w1 worktree-feature)" +session "w1=/home/u/worktree-feature" +run_event workspace.created + +clear_log +: >"$HERDR_AUTOMATIC_RENAME_CONFIG" +workspaces "$(ws w1 wt-feature)" +run_event pane.focused +check_contains "removed rewrite restores derived name" "$(log)" \ + "workspace rename w1 worktree-feature" +teardown + +# ====================================================================== +# Scenario 20: a substitution-only pass does not claim a workspace when no rule +# changes its name, so removing the rules cannot leave stale ownership records. +# ====================================================================== +setup +export AUTO_INDEX=0 +printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \ + >"$HERDR_AUTOMATIC_RENAME_CONFIG" +workspaces "$(ws w1 project-a)" +session "w1=/home/u/project-a" +run_event workspace.created +check "nonmatching rewrite does not rename" "" "$(log)" +check "nonmatching rewrite claims no state" "" \ + "$(jq -r '."ws:w1".auto // ""' "$XDG_STATE_HOME/herdr-automatic-rename/state.json" 2>/dev/null)" +teardown + +# ====================================================================== +# Scenario 21: removing the last rewritten workspace also removes its pending +# restoration state instead of scheduling an empty workspace pass forever. +# ====================================================================== +setup +export AUTO_INDEX=0 +printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \ + >"$HERDR_AUTOMATIC_RENAME_CONFIG" +workspaces "$(ws w1 worktree-feature)" +session "w1=/home/u/worktree-feature" +run_event workspace.created + +: >"$HERDR_AUTOMATIC_RENAME_CONFIG" +workspaces +session "w9=/home/other" +run_event workspace.closed +check "closed rewritten workspace state pruned" "null" \ + "$(jq -r '."ws:w1" | tostring' "$XDG_STATE_HOME/herdr-automatic-rename/state.json" 2>/dev/null)" +teardown + t_summary From e6106211ffba911a86760273f9865ffba307ea92 Mon Sep 17 00:00:00 2001 From: Quan Nguyen <86090707+qu8n@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:26:37 -0400 Subject: [PATCH 2/3] feat(workspace): rewrite a derived label for display WORKSPACE_SUBSTITUTE_SETS shortens the name herdr takes from the directory without touching the directory or the worktree. The rewrite is the sidebar's alone, so tabs go on deduping against the derived name: deduping against the rewrite would have a tab re-inject the long spelling the label was shortened to lose. Numbering is no longer the only reason the workspace pass runs, since a rewrite has to reach a workspace nobody numbered. That leaves the pass needing a way to end: an owned workspace keeps it alive for one more pass once the rules are deleted, which hands the derivation back and drops the record, and herdr resumes labelling from there. `clear` takes the same route, having otherwise stripped the number off a rewrite and left that standing on the way to uninstall. Refs #15 Co-authored-by: Samuel Mendenhall --- CHANGELOG.md | 12 +++ README.md | 1 + automatic-rename.sh | 173 ++++++++++++++++++++++++++++++----- config.example.sh | 17 ++++ naming.sh | 20 +++++ tests/test_context.sh | 46 ++++++++++ tests/test_ws_cwd.sh | 204 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 449 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cac1a5e..386381a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to herdr-automatic-rename are documented here. The format fo ## [Unreleased] +### Added + +- Workspace labels can be shown under a shorter name than their directory carries. `WORKSPACE_SUBSTITUTE_SETS` is an ordered list of `sed -E` rewrites applied to the name herdr derives from the directory, so `'s|^worktree-|wt-|'` puts `worktree-feature` in the sidebar as `wt-feature`. The rewrite reaches the label and nothing else: the directory and the Git worktree keep their names, and a tab inside that directory still drops the workspace's name out of its own label rather than re-injecting the long spelling it was shortened to lose. + + Only a derived name is rewritten. A name you typed is left alone for good, the same promise the tab opt-out makes, with the same gap in it: herdr exposes no way to tell a typed name from the derivation it happens to match exactly. + + Numbering and rewriting are separate reasons for the workspace pass to run, so a rewrite reaches a workspace whose numbering was never turned on. Delete the rules and the derived names come back at the next herdr event or shell prompt, after which the plugin lets those workspaces go and herdr labels them itself again. + + `clear` hands back the derived name too, where it used to strip the number off the rewrite and leave that standing. It is documented as the last step before uninstall, after which the plugin that could have restored the label is gone. + + Based on [#15](https://github.com/qu8n/herdr-automatic-rename/pull/15) by @engineersamuel. + ## [0.10.0] - 2026-09-10 ### Added diff --git a/README.md b/README.md index 02f06c8..35e6f84 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ To customize a config, write it to `~/.config/herdr-automatic-rename/config.sh` | `HIDE_SHELL` | `0` | `1` shows nothing for a plain prompt, so herdr's own number shows through. | | `ICONS_ENABLED` | `0` | Show Nerd Font glyph in front of the name. | | `PROGRAM_ALIASES` | none | Rename programs on the tab: `"lazygit=lg"`. | +| `WORKSPACE_SUBSTITUTE_SETS` | none | Rewrite the workspace label herdr derives from the directory: `'s\|^worktree-\|wt-\|'` shows `worktree-feature` as `wt-feature`. Display only, so the directory and the Git worktree keep their names. | | `MAX_NAME_LEN` `MAX_TITLE_LEN` `MAX_CONTEXT_LEN` `MAX_BRANCH_LEN` | `20` `MAX_NAME_LEN + 8` `12` `12` | Character budget per part of the tab name. | See [config.example.sh](config.example.sh) for the full configuration details. diff --git a/automatic-rename.sh b/automatic-rename.sh index 2e6c77a..508abac 100755 --- a/automatic-rename.sh +++ b/automatic-rename.sh @@ -224,6 +224,28 @@ ar_index_pass() { [ "$CLEAR" = "1" ] || ar_index_on "$1" || ar_index_explicit "$1" } +# ar_ws_pass -> 0 when the workspace pass has work to do, and ar_ws_unwind -> 0 +# when the only work left is to undo what this plugin already wrote. +# +# Numbering is one reason to run (ar_index_pass), WORKSPACE_SUBSTITUTE_SETS is +# the other: a rewrite has to reach a workspace whose numbering was never turned +# on. The third is the one that ends the feature. Deleting the rules leaves the +# rewritten label on screen, and with numbering off nothing would ever run again +# to take it back -- so an owned workspace, which is a workspace this plugin has +# renamed, keeps the pass alive for exactly as long as it takes to hand the +# directory-derived name back and let go of the record. Ownership is the whole +# signal: no record, no pass. +ar_ws_pass() { + ar_index_pass workspaces && return 0 + [ "${#WORKSPACE_SUBSTITUTE_SETS[@]}" -gt 0 ] && return 0 + ar_ws_owned +} +ar_ws_unwind() { + [ "$CLEAR" = "1" ] && return 0 + ar_index_pass workspaces && return 1 + [ "${#WORKSPACE_SUBSTITUTE_SETS[@]}" -eq 0 ] +} + # ar_strip_prefix