diff --git a/CLAUDE.md b/CLAUDE.md index ad1c4a0b8c..45e01995ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1660,9 +1660,79 @@ Notification through, since waiting outranks a colour. Running seats are tracked in `${XDG_STATE_HOME:-~/.local/state}/tabstatus/.seats` — written on SubagentStart, pruned on SubagentStop, deleted on SessionStart/SessionEnd, ignored past a day — so a Stop over a still-running -background seat repaints its colour, not orange. Every detail the shim -writes starts with the repo (the payload cwd's basename), because the row -name is the tab title, often a task title. +background seat repaints its colour, not orange. The detail carries +only the status: the row name carries the repo (next paragraph). + +**The row's name is the session's name, and the shim sets it.** The Session +Status tool labels a row with `session.name` (iTerm2's `ToolStatus.swift`), +which the tab shows too. On iTerm2 3.7.5+ the Claude Code integration's +onboarding leaves a `builtin.claudeCode` workgroup (root session displayName +"Chat", peer "Diff") and, on every profile, an Enter Workgroup trigger that +fires when a job named `claude` starts. Entering renames the leader session to +the root's displayName through a `KEY_NAME` override — that is why every row +read `Chat (claude)`. The `(claude)` is the Job component of iTerm2's job-only +default title (these profiles set no `Title Components`) and is not touched +here. The workgroup's exit trigger restores the shell's own title when +`claude` quits. + +So the shim renames the session back: `it2 session set-name` with `[] ` +(`WORKTABS_GROUP`, when set) and then `$WORKSET` (a working set is N repos, so +the set names the row) else `tabname` of the payload's `cwd`, on a session's +first UserPromptSubmit (marked by an empty `.named` beside the seats +file, written whether or not a rename happened, deleted on +SessionStart/SessionEnd) and on every Stop and StopFailure, so +a later rename by iTerm2 heals at the end of the turn. It runs after the +cc-status call and before the paints, which keeps a paint the last `it2` call of +a turn end. Never on SessionStart: that fires before the job-start trigger may +have, and a rename landing first would be captured as the "original" title the +workgroup restores on exit. The title has control characters dropped and is cut +at 80 characters. `$WORKSET` needs no `tabname`; without it, no `tabname`, no `cwd`, +or a directory outside a repo means no rename, never an empty one (iTerm2 would +show its profile name). `set-name` is +a session-specific in-memory override: **no pref in +`com.googlecode.iterm2.plist` is part of this mechanism** — iTerm2's Claude +Code onboarding writes its workgroup (a `Workgroups` entry, `builtin.claudeCode`) +and the Enter/Exit Workgroup triggers into that plist itself, as it does every +other preference; that is iTerm2 saving, not part of this mechanism, and +clearing the root's displayName in Settings was rejected because it would be a +preference edit in the tracked plist. `cc-status explain` prints `name=` and +`rename=yes|no ()` and `marker=present|none`. A tab already stuck on `Chat (-zsh)` is fixed once +by hand: `it2 session set-name "$(tabname)"` in it, or Session > Edit Session. +The tab bar reads ` (claude)` during a session; the conversation topic no +longer appears there. + +`bin/tabname [dir]` (`~/bin/tabname`) is the label: the git toplevel's name, or +for a linked worktree the repo, an `@`, then the worktree name, so the worktrees +under `.claude/worktrees/` stay told apart. Outside a work tree it prints +nothing and exits 1 — never 0 with empty output. It is a script so the shim and +any other tab labeller (an Alfred switcher) share one answer. The worktree +format is `git config tabname.worktree-format '{repo} {worktree}'`; the +default is ASCII because the toolbelt renders rows in iTerm2's UI font +(`NSFont.it_toolbelt()`), where a Nerd Font private-use glyph is a tofu box +unless proven otherwise. Set a glyph only after checking it in both the Session +Status tool and the tab bar. Tests: `tests/tabname.bats`. + +The `claude()` wrapper writes no title. Where `git config claude.terminal-title +false` is set (machine-local, from the overlay that owns the Mac whose rows the +shim names; default unset, so a public-only clone or a Linux box is untouched) +it exports `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` (`''`, `0`, `false`, `no` and +`off` are normalised to unset under the gate, since Claude Code reads the +variable as a raw string and would treat `0` as disabling; outside the gate a +caller's value passes through untouched), so Claude Code's own OSC 0 titles — +its `/rename`-or-`-n` title, an AI title, a small-model title, and an empty one +on exit — never displace the repo between a title change and the next Stop. It +is gated rather than a default because it costs every machine and pays only +where the shim, iTerm2 3.7.5+ and the integration are all present. Two +documented costs: the docs say the variable also skips the request that +generates an AI session title, so `/resume` rows may show a summary or first +prompt instead (the binary gates that skip behind a feature flag, so it may not +happen), and it turns off Claude Code's OSC 7501 status reports, which the hooks +make redundant. Open `/resume` after a week and drop the config line if it +hurts; the Stop rename converges every turn either way. `claude-doctor`'s +`titles:` line shows the key and the variable. A session not launched through +the wrapper (bare `command claude`, Claude Desktop, an editor terminal) is +still renamed by the shim, but Claude's own titles may displace the name until +the next Stop. A turn that **ends by asking the operator a question** paints `waiting` in yellow (`tabstatus.color.question`, default `#ffd75f`) with the question as @@ -1711,11 +1781,11 @@ and exits 0 at once without the binary, which beats a hook entry that errors on every event. Pass-through events cost one `sh`, a `cat`, two `case` matches and a pipe -into cc-status; a Stop with background tasks, or whose final message contains a `?` (most -Stops), adds one `python3` (~30 ms), a -`stat` per task, one `git config --get-regexp`, and an extra `it2` call only -when nothing is active; an in-seat tool call makes one `it2` call instead of -cc-status's. It fails open to stock behaviour everywhere (no binary, `it2`, +into cc-status; every Stop and StopFailure, and a session's first +UserPromptSubmit, adds one `python3` (~30 ms), a `tabname` and an `it2` start +for the rename (after the turn), a `stat` per task, one `git config +--get-regexp`, and a paint `it2` call only when nothing is active; an in-seat +tool call makes one `it2` call instead of cc-status's. It fails open to stock behaviour everywhere (no binary, `it2`, python3 or session id, bad JSON, `it2` past five seconds) and never exits non-zero: the row is cosmetic and must never cost a turn. It cannot see task types that keep no output file (`monitor`, `in_process_teammate`, diff --git a/bin/executable_tabname b/bin/executable_tabname new file mode 100755 index 0000000000..4b5ab5a6a7 --- /dev/null +++ b/bin/executable_tabname @@ -0,0 +1,60 @@ +#!/bin/sh +# tabname — the label a terminal tab or session row shows for a directory. +# +# tabname [dir] print the label for dir (default .), one line +# +# The git toplevel's name; for a linked worktree "@", so the +# several worktrees of one repo stay told apart. Outside a git work tree +# (not a repo, inside a .git directory, no git) it prints nothing and exits 1 +# — never 0 with empty output, since a caller that writes the label as a +# title would otherwise blank it. +# +# A script rather than a zsh function because more than one caller wants the +# same answer: the cc-status shim naming a session, and any other tab or +# session labeller (an Alfred switcher) that runs it as ~/bin/tabname . +# +# "Worktree" means a linked one, made by `git worktree add`. A submodule is not +# one: its git dir sits under /.git/modules/, and it is labelled by its +# own toplevel like any repo. +# +# The worktree format is machine-local policy: `git config +# tabname.worktree-format '{repo} {worktree}'` (repo-local or global). +# The default is ASCII on purpose — a private-use glyph is a tofu box in any +# surface that does not render with a patched font. +set -u + +dir="${1:-.}" + +out=$(git -C "$dir" rev-parse --path-format=absolute --show-toplevel --git-common-dir 2>/dev/null) || exit 1 +{ + IFS= read -r top + IFS= read -r common +} </dev/null) || fmt= + [ -n "$fmt" ] || fmt='{repo}@{worktree}' + # Substitute with case/${#}, not sed or awk: a directory name may hold the + # replacement metacharacters (& | \) those would interpret. + label= + while [ -n "$fmt" ]; do + case $fmt in + '{repo}'*) label=$label$repo; fmt=${fmt#'{repo}'} ;; + '{worktree}'*) label=$label$worktree; fmt=${fmt#'{worktree}'} ;; + *) rest=${fmt#?}; label=$label${fmt%"$rest"}; fmt=$rest ;; + esac + done +fi + +# A control character in a name must not end or corrupt the OSC the caller +# wraps this in. +label=$(printf '%s' "$label" | tr -d '\000-\037\177') +[ -n "$label" ] || exit 1 +printf '%s\n' "$label" diff --git a/docs/private-overlays.md b/docs/private-overlays.md index f02f0cbb95..adda838ec2 100644 --- a/docs/private-overlays.md +++ b/docs/private-overlays.md @@ -551,6 +551,22 @@ command that is not the bare expanded path, or a shim file that is not executable. Note that 3.7.3 reads only `~/.claude/settings.json` for its health check, so a profile elsewhere is judged by the doctor, not the alert. +A machine that wires the shim also stops Claude Code's own OSC 0 tab titles +from displacing the name the shim sets, with one line in its gitconfig +(`~/.gitconfig.local` from the overlay that owns the Mac, inside a darwin +guard — a Linux box has no iTerm2 and must not get it): + +```gitconfig +[claude] + terminal-title = false +``` + +`claude()` then exports `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`; unset, it +leaves Claude's titles alone, which is why a public-only clone is unchanged. +Land the overlay line first (`dotfiles`), then this repo's change: until both +are in, the row shows Claude's titles between Stops at worst, and the shim's +Stop rename still converges. `claude-doctor`'s `titles:` line shows the value. + ### Secrets — `~/.extra` from `private_dot_extra.tmpl` (personal overlay) Never a value, only a 1Password reference; chezmoi resolves it at apply time diff --git a/dot_claude-shared/CLAUDE.md b/dot_claude-shared/CLAUDE.md index 42135c8e58..5cb531a06b 100644 --- a/dot_claude-shared/CLAUDE.md +++ b/dot_claude-shared/CLAUDE.md @@ -352,7 +352,7 @@ employer-specific — profile-specific memory belongs in that profile's own by hand or by `/model`, `/effort`, `/config` lasts only until the next apply — make the change in the overlay source and apply it. Cross-profile defaults go in the wrapper instead, because an overlay can only speak for one profile. -- **The wrapper exports four defaults**, each overridable per launch: +- **The wrapper exports four defaults** (and one gated export, below), each overridable per launch: `CLAUDE_CODE_SUBAGENT_MODEL=sonnet`; `CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h` and `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` (subagents default to a five-minute cache, and a session on usage credits drops the main conversation to five @@ -367,6 +367,11 @@ employer-specific — profile-specific memory belongs in that profile's own quote: it is not this machine, this model, or this work, and by the rule above nothing here is a saving until `/usage` moves. It injects itself into matching subagents, so its rules are never pasted into a definition. + A fifth, `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`, is not a default: it is + exported only where `claude.terminal-title false` is set (machine-local, + overlay-supplied: the Mac whose Session Status rows the shim names), so + Claude's own tab titles do not displace the repo name; there, set it to `''`, + `0`, `false`, `no` or `off` for one launch to get them back. - **Cache discipline.** Documented invalidators: switching model, changing effort (except Fable 5.1 on a subscription or API key, where effort changes keep the cache), fast mode, MCP connect or disconnect, plugin enable or diff --git a/dot_functions b/dot_functions index da0e1a3435..5b0b0451ae 100644 --- a/dot_functions +++ b/dot_functions @@ -349,21 +349,23 @@ function bin_trace() { # or a literal directory. With no mapping at all, this is plain `claude` on the # default ~/.claude config dir. function claude() { - local dir alias opener here line; + local dir alias opener here line attended; local -a sess; dir="$(claude_profile_dir)" || dir=''; + # An attended launch: somebody is sitting at it. No terminal, print + # mode, a version/help flag, or a first argument shaped like a + # subcommand (`claude mcp …` — the shape, because a list of them would go + # stale) is not one. + if [[ -t 0 && -t 2 && ! $1 =~ '^[a-z][a-z-]*$' ]] && (( ! ${argv[(I)(-p|--print|-h|--help|-v|--version)]} )); then attended=1; fi; # A second session in a directory that already has one puts two writers # on the same tree, and nothing about a fresh prompt says the first is # there. So say so, and ask — a warning with a way past it, never a - # refusal. Only a launch somebody is sitting at is asked: no terminal, - # print mode, a version/help flag, or a first argument shaped like a - # subcommand (`claude mcp …` — the shape, because a list of them would go - # stale) all pass untouched, so hooks, pipes and headless callers never - # block on a question nobody can answer. A helper that fails or finds - # nothing launches as before. Running several sessions in one checkout - # on purpose is a workflow, not a mistake: `git config - # claude.second-session-check false` turns the question off. - if [[ -t 0 && -t 2 && ! $1 =~ '^[a-z][a-z-]*$' ]] && (( ! ${argv[(I)(-p|--print|-h|--help|-v|--version)]} )) && [[ "$(command git config --get claude.second-session-check 2>/dev/null)" != false ]]; then + # refusal. Only an attended launch is asked, so hooks, pipes and + # headless callers never block on a question nobody can answer. A helper + # that fails or finds nothing launches as before. Running several + # sessions in one checkout on purpose is a workflow, not a mistake: `git + # config claude.second-session-check false` turns the question off. + if [[ -n $attended ]] && [[ "$(command git config --get claude.second-session-check 2>/dev/null)" != false ]]; then here="$(claude_sessions_here $dir 2>/dev/null)"; if [[ -n $here ]]; then print -ru2 -- "claude: already running in $PWD:"; @@ -429,6 +431,21 @@ function claude() { # PONYTAIL_SUBAGENT_MATCHER scopes the ponytail plugin's ruleset, when # that plugin is installed, to the seats that write code — a read-only # scout has nothing to keep small. + # CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 is not a default: it is exported + # only where `git config claude.terminal-title false` says so. It keeps + # Claude Code from writing its own OSC 0 tab title (rename/AI/haiku), + # which would compete with the cc-status shim's rename of the Session + # Status row, and that rename exists only where iTerm2 3.7.5+, its + # Claude Code integration's workgroup trigger and the overlay-wired + # hooks are all present. So it is off until machine-local config turns + # it on, and a public-only clone or a Linux box keeps Claude's titles. + # The docs say it also skips the request that generates an AI session + # title (so /resume rows may show a summary instead) and turns off the + # OSC 7501 status reports. Claude Code reads it as a raw string, so `0` + # and `false` would still disable: under the gate those spellings, `no`, + # `off` and the empty string are normalised to unset, which is how one + # launch gets Claude's own titles back. Outside the gate the wrapper has + # no opinion and a caller's value passes through untouched. # These live here and not in each profile's settings.json because each # of those files is owned by that profile's private overlay, and a # default meant for every profile would have to be repeated in each @@ -439,6 +456,12 @@ function claude() { export CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL="${CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL:-1h}"; export CLAUDE_CODE_PROMPT_CACHE_TTL="${CLAUDE_CODE_PROMPT_CACHE_TTL:-1h}"; export PONYTAIL_SUBAGENT_MATCHER="${PONYTAIL_SUBAGENT_MATCHER:-^(work-|unstick-)}"; + if [[ "$(command git config --get claude.terminal-title 2>/dev/null)" == false ]]; then + case ${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-unset} in + unset) export CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1;; + ''|0|[Ff][Aa][Ll][Ss][Ee]|[Nn][Oo]|[Oo][Ff][Ff]) unset CLAUDE_CODE_DISABLE_TERMINAL_TITLE;; + esac; + fi; exec command claude "$@"; ) } @@ -656,6 +679,8 @@ function claude-doctor() { fi; print -r -- "mapping: ${map:-}"; print -r -- "env: CLAUDE_PROFILE=${CLAUDE_PROFILE-} CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-} BROWSER=${BROWSER-} OPEN_AS_ALIAS=${OPEN_AS_ALIAS-}"; + local tt="$(command git config --get claude.terminal-title 2>/dev/null)"; + print -r -- "titles: claude.terminal-title=${tt:-unset} — Claude Code's own tab titles $([[ $tt == false ]] && print -r 'off (the shim names the row)' || print -r on); CLAUDE_CODE_DISABLE_TERMINAL_TITLE=${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-unset} in this shell"; local bare=''; if dir="$(claude_profile_dir)"; then print -r -- "launch: $dir"; diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status index f6a228ad93..6980437400 100755 --- a/dot_local/libexec/cc-status-shim/executable_cc-status +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -39,9 +39,24 @@ # ${XDG_STATE_HOME:-~/.local/state}/tabstatus/.seats, so a Stop # over a still-running background seat paints that seat's colour rather than # amber or orange; SessionStart and SessionEnd delete the file and one older -# than a day is ignored. Every detail the shim writes starts with the repo -# (basename of the payload's cwd): the row name is the tab title, often a task -# title, not the repo. +# than a day is ignored. The row name carries the repo (see below), so the +# detail carries only the status. +# +# The row name is the session's name, and on iTerm2 3.7.5+ the Claude Code +# integration's Enter Workgroup trigger renames the session to the workgroup +# root's displayName ("Chat") when the job `claude` starts, so every row would +# read "Chat (claude)". The shim renames it back to the repo: `it2 session +# set-name "[] " +# --session `, the same KEY_NAME override the workgroup wrote, held in +# memory by iTerm2 — nothing is written to its prefs. It runs on the first +# UserPromptSubmit of a session (marked by .named beside the seats +# file, written whether or not a rename happened) and on every Stop and +# StopFailure, just before the paint, so a later +# rename by iTerm2 heals at the end of the turn. Never on SessionStart: that +# fires before the workgroup's entry may have, and a rename landing first would +# be captured as the "original" title the workgroup restores on exit. The title +# is cut at 80 characters and control characters are dropped. $WORKSET needs +# no tabname; without it, no tabname, no cwd or no repo means no rename. # # A turn that ends by asking the operator something is not idle: on Stop and # StopFailure, with nothing active, a last_assistant_message whose last @@ -69,10 +84,11 @@ # paints; CPU that moved shows as "(cpu busy)" in the quiet paint. No lsof, or # a failed probe, means unknown, never stalled. # -# Cost: python3 now runs on every Stop whose message contains a ? as well as -# on those with background tasks, so on most Stops; the other events still -# cost only the sh gate. lsof and one ps run only on a Stop that has at least -# one quiet shell task. +# Cost: python3 runs on every Stop and StopFailure (the rename needs it) and +# once per session on UserPromptSubmit (the marker is written whether or not a +# rename happened); the other events still cost only the sh gate. A rename adds one tabname and one it2 +# start at the end of the turn. lsof and one ps run only on a Stop that has at +# least one quiet shell task. # # cc-status hook mode: payload on stdin, exit 0 always # cc-status explain payload on stdin; print the decision, call nothing @@ -139,21 +155,27 @@ payload=$(cat) pass() { printf '%s' "$payload" | "$real"; exit 0; } # Cheap gate before paying for python3: Claude Code emits compact JSON. Only -# turn-ending events with background tasks (or, on Stop and StopFailure, a -# final message containing a ?), session start/end (the seat state file) and -# hooks fired inside a seat need the decision; the rest pass through. +# turn-ending events (Stop and StopFailure always: the rename; SubagentStop only +# with background tasks), a session's first UserPromptSubmit (no .named marker +# yet), session start/end (the seat state file) and hooks fired inside a seat +# need the decision; the rest pass through. # A seat is an agent_type with a default colour (keep in step with SEATS below) # or a tabstatus.color. setting. if [ "$mode" != explain ]; then go= case $payload in - *'"hook_event_name":"Stop"'*|*'"hook_event_name":"StopFailure"'*|*'"hook_event_name":"SubagentStop"'*) + *'"hook_event_name":"Stop"'*|*'"hook_event_name":"StopFailure"'*) go=1 ;; + *'"hook_event_name":"SubagentStop"'*) + case $payload in *'"background_tasks":[{'*) go=1 ;; esac ;; + *'"hook_event_name":"SessionStart"'*|*'"hook_event_name":"SessionEnd"'*) go=1 ;; + *'"hook_event_name":"UserPromptSubmit"'*) + # Named already? A session id that is not a plain token lets python decide. + go=1 case $payload in - *'"background_tasks":[{'*) go=1 ;; - *'"hook_event_name":"SubagentStop"'*) ;; - *'"last_assistant_message":"'*'?'*|*'"last_assistant_message":"'*'?'*) go=1 ;; + *'"session_id":"'*) + nid=${payload#*'"session_id":"'}; nid=${nid%%'"'*} + [ -e "${XDG_STATE_HOME:-$HOME/.local/state}/tabstatus/$nid.named" ] && go= ;; esac ;; - *'"hook_event_name":"SessionStart"'*|*'"hook_event_name":"SessionEnd"'*) go=1 ;; esac case $payload in *'"agent_type":"'*) @@ -172,7 +194,7 @@ fi # The script goes in via -c so stdin stays free for the payload: `python3 -` # with a heredoc would make the heredoc the stdin and the payload vanish. py=$(cat <<'PY' -import functools, json, os, re, subprocess, sys, time +import functools, json, os, re, shutil, subprocess, sys, time mode, real, it2, sid = sys.argv[1:5] explain = mode == "explain" @@ -230,7 +252,6 @@ except (ValueError, AttributeError): tasks, aid, agent_type = payload.get("background_tasks"), payload.get("agent_id"), payload.get("agent_type") seat_col = seat_color(agent_type) seat = agent_type if seat_col else None -repo = os.path.basename(str(payload.get("cwd") or "").rstrip("/")) # Seat state: one "\t" line per running seat of a session. ID = re.compile(r"[A-Za-z0-9_-]+") @@ -238,6 +259,7 @@ pid = payload.get("session_id") state_dir = os.path.join(os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), "tabstatus") sf = os.path.join(state_dir, pid + ".seats") if isinstance(pid, str) and ID.fullmatch(pid) else None tf = sf[:-len(".seats")] + ".tasks" if sf else None +nf = sf[:-len(".seats")] + ".named" if sf else None def seats_read(): try: @@ -273,7 +295,7 @@ elif event == "SubagentStop" and aid: def paint(status, detail, dot, text, count=None): # The full field set every time, whatever cc-status did or failed to do. - d = " ".join((f"{repo} · {detail}" if repo else detail).split()) + d = " ".join(detail.split()) if len(d) > 180: d = d[:180] + "…" argv = [it2, "set-status", "--session", sid, "--status", status, "--detail", d, @@ -418,6 +440,29 @@ if event in ("Stop", "StopFailure") and not active and isinstance(msg, str): if q.endswith(("?", "?")): question = q +# The session's name: "[] ", or None with the reason it is not renamed. +def session_title(): + if seat or event not in ("UserPromptSubmit", "Stop", "StopFailure"): + return None, "event" + if event == "UserPromptSubmit" and nf and os.path.exists(nf): + return None, "already named" + if not sid: + return None, "no session id" + if not it2: + return None, "no it2" + clean = lambda v: re.sub(r"[\x00-\x1f\x7f]", "", v or "").strip() + name = clean(os.environ.get("WORKSET")) # a set names the row; no tabname needed + if not name: + tn = shutil.which("tabname") or os.path.join(os.path.expanduser("~"), "bin", "tabname") + if not os.access(tn, os.X_OK): + return None, "no tabname" + cwd = payload.get("cwd") + name = clean(run_out([tn, cwd]) if isinstance(cwd, str) and cwd else None) + if not name: + return None, "not a repo" + group = clean(os.environ.get("WORKTABS_GROUP")) + return ((f"[{group}] " if group else "") + name)[:80], "" + # A running seat among the active tasks: the row stays that seat's colour, not the parent's. ids = {t.get("task_id") for t in active if isinstance(t, dict)} busy = [t for a, t in live if a in ids and seat_color(t)] @@ -439,6 +484,10 @@ if explain: print(f"seat={seat or 'none'} colour={seat_col or 'none'} state={len(live)}") print(f"question={'yes ' + question if question else 'no'}") print(f"action={action}") + title, why = session_title() + print(f"name={title or 'none'}") + print(f"rename={'yes' if title else 'no'} ({why or 'ok'})") + print(f"marker={'present' if nf and os.path.exists(nf) else 'none'}") print(f"cc-status={real or 'absent'}") print(f"it2={it2 or 'absent'}") sys.exit(0) @@ -447,6 +496,10 @@ if live != before or event in ("SessionStart", "SessionEnd"): seats_write(live) if event in ("SessionStart", "SessionEnd"): tasks_write({}) + try: + os.remove(nf) + except (OSError, TypeError): + pass if in_seat_tool: tool = payload.get("tool_name") paint("working", f"{seat}: {tool}" if tool else seat, seat_col, seat_col) @@ -457,6 +510,18 @@ if fed is None: else: payload["background_tasks"] = fed call([real], json.dumps(payload).encode(), 10) + +# Before the paints, so the last it2 call of a turn end is still the paint. +title, _ = session_title() +# The first prompt is marked whatever the outcome, so python starts once per session for it. +if event == "UserPromptSubmit" and nf and not seat: + try: + os.makedirs(state_dir, exist_ok=True) + open(nf, "w").close() + except OSError: + pass +if title: + call([it2, "session", "set-name", title, "--session", sid], silent=True) if event == "SubagentStart" and seat: paint("working", f"{seat} running", seat_col, seat_col) if paint_stalled: diff --git a/tests/cc-status-shim.bats b/tests/cc-status-shim.bats index b4d7426c59..4b3a69157e 100644 --- a/tests/cc-status-shim.bats +++ b/tests/cc-status-shim.bats @@ -5,7 +5,9 @@ # one directory, the way iTerm2 ships them side by side: cc-status records # the payload it was fed, it2 records its argv. No real iTerm2 is touched. # lsof and ps are stubbed there too (and that directory put on PATH): they print -# lsof.out and ps.out, fixtures a test overwrites, and record their argv. +# lsof.out and ps.out, fixtures a test overwrites, and record their argv. So is +# tabname: proj for a cwd ending in /proj, proj@feat-x for /feat-x, else nothing +# and exit 1. it2 also appends every argv to argv_all, one call per block. bats_require_minimum_version 1.5.0 @@ -13,10 +15,20 @@ setup() { STUB="$BATS_TEST_TMPDIR/utilities" mkdir -p "$STUB" printf '#!/bin/sh\ncat > "%s/fed"\n' "$BATS_TEST_TMPDIR" > "$STUB/cc-status" - printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/argv"\necho x >> "%s/calls"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/it2" + printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/argv"\nprintf "%%s\\n" "$@" >> "%s/argv_all"\necho x >> "%s/calls"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/it2" + cat > "$STUB/tabname" <<'SH' +#!/bin/sh +echo "$1" >> "$(dirname "$0")/../tabname_argv" +case $1 in + */feat-x) echo proj@feat-x ;; + */proj) echo proj ;; + *) exit 1 ;; +esac +SH printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/lsof_argv"\ncat "%s/lsof.out"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/lsof" printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/ps_argv"\ncat "%s/ps.out"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/ps" - chmod +x "$STUB/cc-status" "$STUB/it2" "$STUB/lsof" "$STUB/ps" + chmod +x "$STUB/cc-status" "$STUB/it2" "$STUB/lsof" "$STUB/ps" "$STUB/tabname" + unset WORKSET WORKTABS_GROUP export PATH="$STUB:$PATH" # By default one shell (4242) holds every output file, with a child: 3.5 s of CPU in all. echo 4242 > "$BATS_TEST_TMPDIR/lsof.out" @@ -69,6 +81,12 @@ fed_tasks() { python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.a run_shim() { run sh "$SHIM"; } +# rename_only: the cwd's Stop renamed the session and painted nothing. +rename_only() { + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] + [ "$(it2_argv)" = "session|set-name|proj|--session|ABC-123" ] +} + @test "shim: a non-Stop event passes through byte for byte and never calls it2" { local p='{"hook_event_name":"PreToolUse","tool_name":"Bash","background_tasks":[{"task_id":"t1"}]}' run_shim <<< "$p" @@ -303,7 +321,7 @@ PY run_shim <<< "$(seat_payload PreToolUse plan-fable '"tool_name":"Bash"')" [ "$status" -eq 0 ] [ ! -e "$BATS_TEST_TMPDIR/fed" ] - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · plan-fable: Bash|--dot-color|#d787ff|--text-color|#d787ff" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|plan-fable: Bash|--dot-color|#d787ff|--text-color|#d787ff" ] } @test "seat: every default colour, and a PostToolUse, paint the same way" { @@ -311,7 +329,7 @@ PY for pair in unstick-fable:#ff5faf review-fable:#af87ff work-sonnet:#00afaf scout-haiku:#87af5f; do seat=${pair%%:*} col=${pair#*:} run_shim <<< "$(seat_payload PostToolUse "$seat" '"tool_name":"Edit"')" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · $seat: Edit|--dot-color|$col|--text-color|$col" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|$seat: Edit|--dot-color|$col|--text-color|$col" ] done } @@ -353,7 +371,7 @@ PY [ "$status" -eq 0 ] [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] [ "$(cat "$SEATS_FILE")" = "$(printf 'ag1\twork-sonnet')" ] - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · work-sonnet running|--dot-color|#00afaf|--text-color|#00afaf" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|work-sonnet running|--dot-color|#00afaf|--text-color|#00afaf" ] run_shim <<< "$p" # a repeat does not duplicate [ "$(wc -l < "$SEATS_FILE")" -eq 1 ] } @@ -395,8 +413,8 @@ PY run_shim <<< "$(payload Stop '[{"task_id":"ag1","task_type":"local_agent"}]' '"cwd":"/work/proj","session_id":"s1"')" [ "$status" -eq 0 ] [ "$(fed_tasks | grep -c '"ag1"')" -eq 1 ] - [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · plan-fable running · 1 background task running|--dot-color|#d787ff|--text-color|#d787ff|--background-tasks|1" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 2 ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|plan-fable running · 1 background task running|--dot-color|#d787ff|--text-color|#d787ff|--background-tasks|1" ] } @test "seat: two live seats name both, in the first one's colour, counting every active task" { @@ -406,7 +424,7 @@ PY mkdir -p "$(dirname "$SEATS_FILE")" printf 'ag1\twork-sonnet\nag2\tplan-fable\n' > "$SEATS_FILE" run_shim <<< "$(payload Stop '[{"task_id":"ag1"},{"task_id":"ag2"},{"task_id":"sh","task_type":"local_bash"}]' '"cwd":"/work/proj","session_id":"s1"')" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · work-sonnet, plan-fable running · 3 background tasks running|--dot-color|#00afaf|--text-color|#00afaf|--background-tasks|3" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|work-sonnet, plan-fable running · 3 background tasks running|--dot-color|#00afaf|--text-color|#00afaf|--background-tasks|3" ] } @test "seat: a seat in the state file that is not among the active tasks paints nothing" { @@ -414,7 +432,7 @@ PY mkdir -p "$(dirname "$SEATS_FILE")" printf 'gone\tplan-fable\n' > "$SEATS_FILE" run_shim <<< "$(payload Stop '[{"task_id":"sh","task_type":"local_bash"}]' '"cwd":"/work/proj","session_id":"s1"')" - [ ! -e "$BATS_TEST_TMPDIR/calls" ] + rename_only } @test "seat: a quiet seat is not painted as running; the amber idle wins" { @@ -422,7 +440,7 @@ PY mkdir -p "$(dirname "$SEATS_FILE")" printf 'ag1\tplan-fable\n' > "$SEATS_FILE" run_shim <<< "$(payload Stop '[{"task_id":"ag1","task_type":"local_agent","description":"stuck"}]' '"cwd":"/work/proj","session_id":"s1"')" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|proj · idle · 1 quiet background task, quiet 120 min: stuck|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|idle · 1 quiet background task, quiet 120 min: stuck|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] } @test "seat: a state file older than a day is ignored and removed" { @@ -431,7 +449,7 @@ PY printf 'ag1\tplan-fable\n' > "$SEATS_FILE" python3 -c 'import os,sys,time; t=time.time()-25*3600; os.utime(sys.argv[1],(t,t))' "$SEATS_FILE" run_shim <<< "$(payload Stop '[{"task_id":"ag1","task_type":"local_agent"}]' '"cwd":"/work/proj","session_id":"s1"')" - [ ! -e "$BATS_TEST_TMPDIR/calls" ] + rename_only [ ! -e "$SEATS_FILE" ] } @@ -441,10 +459,11 @@ PY [ -z "$(find "$BATS_TEST_TMPDIR" -name '*.seats')" ] } -@test "repo: the quiet-task detail starts with the cwd's basename" { +@test "repo: the detail never repeats the cwd's basename; the row name carries it" { age_file a 7200 run_shim <<< "$(payload Stop '[{"task_id":"a","description":"tail -F build.log"}]' '"cwd":"/work/proj/"')" - grep -qx 'proj · idle · 1 quiet background task, quiet 120 min: tail -F build.log' "$BATS_TEST_TMPDIR/argv" + grep -qx 'idle · 1 quiet background task, quiet 120 min: tail -F build.log' "$BATS_TEST_TMPDIR/argv" + ! grep -q '^proj' "$BATS_TEST_TMPDIR/argv" } @test "explain: a seat event names the seat, its colour and the live state, and writes nothing" { @@ -469,40 +488,40 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi run_shim <<< "$p" [ "$status" -eq 0 ] [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] - [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · Which branch should I use?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 2 ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|Which branch should I use?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] } @test "question: StopFailure is handled like Stop, and a full-width question mark counts" { run_shim <<< "$(payload StopFailure '[]' '"cwd":"/work/proj","last_assistant_message":"どちらにしますか?"')" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · どちらにしますか?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|どちらにしますか?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] } @test "question: a message ending in a statement paints nothing, even with a question earlier" { local p; p=$(question_payload 'Is it ok?\n\nYes, it is.') run_shim <<< "$p" [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] - [ ! -e "$BATS_TEST_TMPDIR/calls" ] + rename_only } @test "question: with an active task the shim paints nothing and cc-status keeps the row working" { age_file a 5 run_shim <<< "$(question_payload 'Which branch?' '[{"task_id":"a","task_type":"local_bash"}]')" [ "$(fed_tasks | grep -c '"a"')" -eq 1 ] - [ ! -e "$BATS_TEST_TMPDIR/calls" ] + rename_only } @test "question: beside only a quiet task the question paint wins over the amber idle" { age_file a 7200 run_shim <<< "$(question_payload 'Which branch?' '[{"task_id":"a","task_type":"local_bash","description":"stuck"}]')" [ "$(fed_tasks)" = "[]" ] - [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · Which branch?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 2 ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|Which branch?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] } @test "question: leading bullets, quotes and spaces, and trailing spaces, are stripped" { run_shim <<< "$(question_payload 'Fine.\n\n> - is this right? \n\n')" - grep -qx 'proj · is this right?' "$BATS_TEST_TMPDIR/argv" + grep -qx 'is this right?' "$BATS_TEST_TMPDIR/argv" } @test "question: a long question is cut like any other detail" { @@ -522,7 +541,7 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi @test "question: tabstatus.color.question overrides the colour; a bad value falls back with a note" { git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.question '#112233' run_shim <<< "$(question_payload 'Which branch?')" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · Which branch?|--dot-color|#112233|--text-color|#112233|--background-tasks|0" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|Which branch?|--dot-color|#112233|--text-color|#112233|--background-tasks|0" ] git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.question 'yellow' run --separate-stderr sh "$SHIM" <<< "$(question_payload 'Which branch?')" @@ -534,7 +553,7 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi local p; p=$(question_payload 'All done.') run_shim <<< "$p" [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] - [ ! -e "$BATS_TEST_TMPDIR/calls" ] + rename_only } @test "explain: prints question=yes with the extracted line, or question=no" { @@ -551,7 +570,7 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi age_file a 7200 run_shim <<< "$(stall_payload)" [ "$status" -eq 0 ] - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|proj · idle · 1 quiet background task, quiet 120 min: install.sh --all|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|idle · 1 quiet background task, quiet 120 min: install.sh --all|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/lsof_argv")" = "-t|--|$SESS/tasks/a.output" ] [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/ps_argv")" = "-axo|pid,ppid,time" ] [ "$(wc -l < "$TASKS_FILE")" -eq 1 ] @@ -562,7 +581,7 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi age_file a 7200 task_state a 965 3.50 run_shim <<< "$(stall_payload)" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · stalled 16 min: install.sh --all|--dot-color|#ff5f5f|--text-color|#ff5f5f|--background-tasks|0" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|stalled 16 min: install.sh --all|--dot-color|#ff5f5f|--text-color|#ff5f5f|--background-tasks|0" ] [ "$(fed_tasks)" = "[]" ] } @@ -571,10 +590,10 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi task_state a 965 3.50 set_cpu 1.00 7.50 run_shim <<< "$(stall_payload)" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|proj · idle · 1 quiet background task, quiet 120 min: install.sh --all (cpu busy)|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|idle · 1 quiet background task, quiet 120 min: install.sh --all (cpu busy)|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] [ "$(cut -f3 "$TASKS_FILE")" = "8.50" ] run_shim <<< "$(stall_payload)" # nothing moved since, but the clock restarted a moment ago - grep -qx 'proj · idle · 1 quiet background task, quiet 120 min: install.sh --all' "$BATS_TEST_TMPDIR/argv" + grep -qx 'idle · 1 quiet background task, quiet 120 min: install.sh --all' "$BATS_TEST_TMPDIR/argv" } @test "stalled: quiet for less than stall-minutes is not stalled, and the record keeps its first sighting" { @@ -598,7 +617,7 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi age_file a 7200 : > "$BATS_TEST_TMPDIR/lsof.out" run_shim <<< "$(stall_payload)" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · stalled 120 min: install.sh --all|--dot-color|#ff5f5f|--text-color|#ff5f5f|--background-tasks|0" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|stalled 120 min: install.sh --all|--dot-color|#ff5f5f|--text-color|#ff5f5f|--background-tasks|0" ] [ ! -e "$BATS_TEST_TMPDIR/ps_argv" ] } @@ -617,7 +636,7 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi age_file a 7200 : > "$BATS_TEST_TMPDIR/lsof.out" run_shim <<< "$(stall_payload '"last_assistant_message":"Which branch?"')" - grep -qx 'proj · stalled 120 min: install.sh --all' "$BATS_TEST_TMPDIR/argv" + grep -qx 'stalled 120 min: install.sh --all' "$BATS_TEST_TMPDIR/argv" grep -qx 'waiting' "$BATS_TEST_TMPDIR/argv" } @@ -635,7 +654,7 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.stalled '#112233' git config -f "$GIT_CONFIG_GLOBAL" tabstatus.stall-minutes 5 run_shim <<< "$(stall_payload)" - [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · stalled 6 min: install.sh --all|--dot-color|#112233|--text-color|#112233|--background-tasks|0" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|stalled 6 min: install.sh --all|--dot-color|#112233|--text-color|#112233|--background-tasks|0" ] git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.stalled 'red' run --separate-stderr sh "$SHIM" <<< "$(stall_payload)" @@ -685,3 +704,188 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi [ ! -e "$BATS_TEST_TMPDIR/calls" ] [ ! -e "$BATS_TEST_TMPDIR/fed" ] } + +# named_payload [cwd] [extra-json-members]: a hook with a session id and a cwd, no tasks. +named_payload() { printf '{"hook_event_name":"%s","session_id":"s1","cwd":"%s"%s}' "$1" "${2:-/work/proj}" "${3:+,$3}"; } +NAMED_FILE() { printf '%s/tabstatus/s1.named' "$XDG_STATE_HOME"; } + +@test "rename: the first UserPromptSubmit names the session after the repo and writes the marker" { + local p; p=$(named_payload UserPromptSubmit) + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(it2_argv)" = "session|set-name|proj|--session|ABC-123" ] + [ -e "$(NAMED_FILE)" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] +} + +@test "rename: a second UserPromptSubmit does nothing" { + local p; p=$(named_payload UserPromptSubmit) + run_shim <<< "$p" + rm "$BATS_TEST_TMPDIR/calls" "$BATS_TEST_TMPDIR/fed" "$BATS_TEST_TMPDIR/tabname_argv" + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ ! -e "$BATS_TEST_TMPDIR/tabname_argv" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] +} + +@test "rename: Stop renames after cc-status and before the paint, so the paint is the last it2 call" { + age_file a 7200 + run_shim <<< "$(stall_payload)" + [ "$status" -eq 0 ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 2 ] + [ "$(it2_argv | cut -d'|' -f1)" = "set-status" ] + [ "$(grep -cx 'set-name' "$BATS_TEST_TMPDIR/argv_all")" -eq 1 ] + [ "$(head -3 "$BATS_TEST_TMPDIR/argv_all" | paste -sd'|' -)" = "session|set-name|proj" ] +} + +@test "rename: a plain Stop with no tasks and no question still renames" { + run_shim <<< "$(named_payload Stop)" + [ "$status" -eq 0 ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] + [ "$(it2_argv)" = "session|set-name|proj|--session|ABC-123" ] + [ ! -e "$(NAMED_FILE)" ] +} + +@test "rename: WORKSET names the set and WORKTABS_GROUP prefixes it" { + export WORKSET=myset WORKTABS_GROUP=personal + run_shim <<< "$(named_payload Stop)" + [ "$(it2_argv)" = "session|set-name|[personal] myset|--session|ABC-123" ] + unset WORKSET + run_shim <<< "$(named_payload Stop)" + [ "$(it2_argv)" = "session|set-name|[personal] proj|--session|ABC-123" ] +} + +@test "rename: a worktree cwd gets the worktree label" { + run_shim <<< "$(named_payload Stop /work/proj/.claude/worktrees/feat-x)" + [ "$(it2_argv)" = "session|set-name|proj@feat-x|--session|ABC-123" ] + [ "$(cat "$BATS_TEST_TMPDIR/tabname_argv")" = "/work/proj/.claude/worktrees/feat-x" ] +} + +@test "rename: outside a repo, without tabname, or without it2 nothing is renamed" { + # Stop: nothing renamed, the payload still fed to cc-status. + local p; p=$(named_payload Stop /elsewhere) + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + + p=$(named_payload Stop) + rm "$STUB/tabname" "$BATS_TEST_TMPDIR/fed" + PATH="$STUB:/usr/bin:/bin" run sh "$SHIM" <<< "$p" + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + + rm "$STUB/it2" "$BATS_TEST_TMPDIR/fed" + PATH="/usr/bin:/bin" run sh "$SHIM" <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] +} + +@test "rename: the first UserPromptSubmit is marked whatever the outcome, and the second skips python" { + # not a repo + local p; p=$(named_payload UserPromptSubmit /elsewhere) + run_shim <<< "$p" + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ -e "$(NAMED_FILE)" ] + rm "$BATS_TEST_TMPDIR/tabname_argv" "$BATS_TEST_TMPDIR/fed" + run_shim <<< "$p" + [ ! -e "$BATS_TEST_TMPDIR/tabname_argv" ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + + # no tabname + rm "$(NAMED_FILE)" "$STUB/tabname" "$BATS_TEST_TMPDIR/fed" + p=$(named_payload UserPromptSubmit) + PATH="$STUB:/usr/bin:/bin" run sh "$SHIM" <<< "$p" + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ -e "$(NAMED_FILE)" ] + rm "$BATS_TEST_TMPDIR/fed" + PATH="$STUB:/usr/bin:/bin" run sh "$SHIM" <<< "$p" + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + + # no it2: the sh gate passes the payload through before python starts, so there is no marker to write + rm "$(NAMED_FILE)" "$STUB/it2" "$BATS_TEST_TMPDIR/fed" + PATH="/usr/bin:/bin" run sh "$SHIM" <<< "$p" + [ "$status" -eq 0 ] + [ ! -e "$(NAMED_FILE)" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] +} + +@test "rename: WORKSET renames without tabname" { + export WORKSET=myset + rm "$STUB/tabname" + PATH="$STUB:/usr/bin:/bin" run sh "$SHIM" <<< "$(named_payload UserPromptSubmit)" + [ "$status" -eq 0 ] + [ "$(it2_argv)" = "session|set-name|myset|--session|ABC-123" ] + [ -e "$(NAMED_FILE)" ] +} + +@test "rename: WORKSET renames outside a repo" { + export WORKSET=myset + run_shim <<< "$(named_payload UserPromptSubmit /nowhere)" + [ "$(it2_argv)" = "session|set-name|myset|--session|ABC-123" ] + [ ! -e "$BATS_TEST_TMPDIR/tabname_argv" ] +} + +@test "rename: control characters are stripped and the title is cut at 80" { + export WORKSET=$'a\tb' + run_shim <<< "$(named_payload Stop)" + [ "$(it2_argv)" = "session|set-name|ab|--session|ABC-123" ] + export WORKSET=$(printf 'x%.0s' $(seq 120)) + run_shim <<< "$(named_payload Stop)" + [ "$(sed -n 3p "$BATS_TEST_TMPDIR/argv" | wc -c)" -eq 81 ] + export WORKSET=$'\t\n' + run_shim <<< "$(named_payload Stop)" + [ "$(it2_argv)" = "session|set-name|proj|--session|ABC-123" ] +} + +@test "rename: SubagentStop and in-seat events never rename" { + run_shim <<< "$(payload SubagentStop '[{"task_id":"a"}]' '"cwd":"/work/proj","session_id":"s1","agent_id":"zz"')" + run_shim <<< "$(seat_payload PreToolUse plan-fable '"tool_name":"Bash"')" + run_shim <<< "$(seat_payload Stop plan-fable)" + run_shim <<< "$(seat_payload UserPromptSubmit plan-fable)" + [ ! -e "$BATS_TEST_TMPDIR/tabname_argv" ] + ! grep -qx 'set-name' "$BATS_TEST_TMPDIR/argv_all" +} + +@test "rename: SessionStart and SessionEnd delete the marker and never rename" { + local ev + for ev in SessionStart SessionEnd; do + mkdir -p "$(dirname "$(NAMED_FILE)")" + : > "$(NAMED_FILE)" + run_shim <<< "$(named_payload "$ev")" + [ ! -e "$(NAMED_FILE)" ] + done + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "explain: prints name= and rename= with the reason" { + run sh "$SHIM" explain <<< "$(named_payload Stop)" + [[ $output == *"name=proj"* && $output == *"rename=yes"* && $output == *"marker=none"* ]] + run sh "$SHIM" explain <<< "$(named_payload PreToolUse)" + [[ $output == *"name=none"* && $output == *"rename=no (event)"* ]] + mkdir -p "$(dirname "$(NAMED_FILE)")" + : > "$(NAMED_FILE)" + run sh "$SHIM" explain <<< "$(named_payload UserPromptSubmit)" + [[ $output == *"rename=no (already named)"* && $output == *"marker=present"* ]] + run sh "$SHIM" explain <<< "$(named_payload Stop /elsewhere)" + [[ $output == *"name=none"* && $output == *"rename=no (not a repo)"* ]] + rm "$(NAMED_FILE)" + run sh "$SHIM" explain <<< "$(named_payload UserPromptSubmit)" + [[ $output == *"rename=yes"* ]] + [ ! -e "$(NAMED_FILE)" ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "explain: marker= is none before the first prompt (explain never writes) and present after a real run" { + local p; p=$(named_payload UserPromptSubmit) + run sh "$SHIM" explain <<< "$p" + [[ $output == *"marker=none"* ]] + [ ! -e "$(NAMED_FILE)" ] + run_shim <<< "$p" + run sh "$SHIM" explain <<< "$p" + [[ $output == *"marker=present"* && $output == *"rename=no (already named)"* ]] +} diff --git a/tests/chezmoi.bats b/tests/chezmoi.bats index c3022cc2ea..9910118841 100644 --- a/tests/chezmoi.bats +++ b/tests/chezmoi.bats @@ -57,6 +57,7 @@ teardown() { [ -f "$TMPHOME/.aliases" ] [ -f "$TMPHOME/.functions" ] [ -x "$TMPHOME/bin/has-glyphs" ] + [ -x "$TMPHOME/bin/tabname" ] # Rendered shell files parse zsh -n "$TMPHOME/.zshrc" zsh -n "$TMPHOME/.aliases" diff --git a/tests/claude-profiles.bats b/tests/claude-profiles.bats index 3dd89548ee..584cd3f6de 100644 --- a/tests/claude-profiles.bats +++ b/tests/claude-profiles.bats @@ -31,7 +31,8 @@ setup() { # be inherited rather than applied. unset CLAUDE_PROFILE CLAUDE_CONFIG_DIR BROWSER OPEN_AS_ALIAS SSH_CONNECTION \ CLAUDE_CODE_SUBAGENT_MODEL CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL \ - CLAUDE_CODE_PROMPT_CACHE_TTL PONYTAIL_SUBAGENT_MATCHER + CLAUDE_CODE_PROMPT_CACHE_TTL PONYTAIL_SUBAGENT_MATCHER \ + CLAUDE_CODE_DISABLE_TERMINAL_TITLE # Fixture pins: one "work" org and one "personal" owner, mapped to two # accounts; work maps to the default dir, personal to a separate profile. @@ -167,6 +168,16 @@ claude_in() { [[ "$output" == *"defined: MISSING claude_profile_dir — a shell snapshot or partial source dropped them; source ~/.functions again"* ]] } +@test "claude-doctor: titles line reports the claude.terminal-title gate" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor" + [[ "$output" == *"titles: claude.terminal-title=unset"* ]] + git config --file "$GIT_CONFIG_GLOBAL" claude.terminal-title false + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor" + [[ "$output" == *"titles: claude.terminal-title=false"* ]] + [[ "$output" == *"off (the shim names the row)"* ]] +} + @test "claude-doctor: names the deny guard's rule count and fail-closed wiring" { repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') # denyguard itself needs a real python3 (see setup). @@ -325,6 +336,57 @@ PY [ "$output" = "SUBAGENT=opus" ] } +@test "claude: Claude Code's own titles are left alone by default" { + printf '#!/bin/sh\necho "TITLE=${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-UNSET}"\n' \ + > "$BATS_TEST_TMPDIR/bin/claude" + run zsh -c "source '$DOTFUNCTIONS'; claude" + [ "$status" -eq 0 ] + [ "$output" = "TITLE=UNSET" ] +} + +@test "claude: an explicit value passes through untouched when the gate is off" { + printf '#!/bin/sh\necho "TITLE=${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-UNSET}"\n' \ + > "$BATS_TEST_TMPDIR/bin/claude" + run zsh -c "source '$DOTFUNCTIONS'; CLAUDE_CODE_DISABLE_TERMINAL_TITLE=0 claude" + [ "$status" -eq 0 ] + [ "$output" = "TITLE=0" ] + run zsh -c "source '$DOTFUNCTIONS'; CLAUDE_CODE_DISABLE_TERMINAL_TITLE=yes claude" + [ "$status" -eq 0 ] + [ "$output" = "TITLE=yes" ] +} + +@test "claude: claude.terminal-title false turns Claude's titles off" { + printf '#!/bin/sh\necho "TITLE=${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-UNSET}"\n' \ + > "$BATS_TEST_TMPDIR/bin/claude" + git config --file "$GIT_CONFIG_GLOBAL" claude.terminal-title false + run zsh -c "source '$DOTFUNCTIONS'; claude" + [ "$status" -eq 0 ] + [ "$output" = "TITLE=1" ] +} + +@test "claude: under the gate an empty, 0, false, no or off value gives one launch Claude's titles back" { + # Claude Code reads the variable as a raw string, so any non-empty value + # disables titles; under the gate the wrapper turns the off-spellings into + # unset. + printf '#!/bin/sh\necho "TITLE=${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-UNSET}"\n' \ + > "$BATS_TEST_TMPDIR/bin/claude" + git config --file "$GIT_CONFIG_GLOBAL" claude.terminal-title false + for v in '' 0 false No OFF; do + run zsh -c "source '$DOTFUNCTIONS'; CLAUDE_CODE_DISABLE_TERMINAL_TITLE='$v' claude" + [ "$status" -eq 0 ] + [ "$output" = "TITLE=UNSET" ] + done +} + +@test "claude: under the gate any other set value is kept" { + printf '#!/bin/sh\necho "TITLE=${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-UNSET}"\n' \ + > "$BATS_TEST_TMPDIR/bin/claude" + git config --file "$GIT_CONFIG_GLOBAL" claude.terminal-title false + run zsh -c "source '$DOTFUNCTIONS'; CLAUDE_CODE_DISABLE_TERMINAL_TITLE=yes claude" + [ "$status" -eq 0 ] + [ "$output" = "TITLE=yes" ] +} + @test "claude: CLAUDE_PROFILE typo warns on stderr and falls back to the default profile" { # A typo'd account name must not become a relative CLAUDE_CONFIG_DIR — the # wrapper warns and launches the default profile instead, even in a repo diff --git a/tests/tabname.bats b/tests/tabname.bats new file mode 100644 index 0000000000..22acfaa2ea --- /dev/null +++ b/tests/tabname.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats + +# ~/bin/tabname prints the label a tab shows for a directory: the repo's name, +# "@" for a linked worktree, nothing and exit 1 outside a git +# work tree. Everything runs in a sandboxed HOME against fixture repos with +# invented names. + +setup() { + BIN="$BATS_TEST_DIRNAME/../bin/executable_tabname" + export HOME="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME" + export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_NOSYSTEM=1 + git config --global user.name octo + git config --global user.email octo@example.com + REPO="$BATS_TEST_TMPDIR/proj" + make_repo "$REPO" +} + +# A repo at $1 with one commit. +make_repo() { + mkdir -p "$1" + git -C "$1" -c init.defaultBranch=trunk init -q + echo one >"$1/tracked.txt" + git -C "$1" add . + git -C "$1" commit -qm 'initial commit' +} + +@test "main checkout: the repo's name" { + cd "$REPO" + run sh "$BIN" + [ "$status" -eq 0 ] + [ "$output" = proj ] +} + +@test "a subdirectory of the checkout gives the same name" { + mkdir "$REPO/sub" + cd "$REPO/sub" + run sh "$BIN" + [ "$status" -eq 0 ] + [ "$output" = proj ] +} + +@test "an explicit directory argument is honoured from an unrelated cwd" { + cd "$BATS_TEST_TMPDIR" + run sh "$BIN" "$REPO" + [ "$status" -eq 0 ] + [ "$output" = proj ] +} + +@test "a linked worktree is repo@worktree" { + git -C "$REPO" worktree add -q "$REPO/.claude/worktrees/feat-x" -b feat-x + cd "$REPO/.claude/worktrees/feat-x" + run sh "$BIN" + [ "$status" -eq 0 ] + [ "$output" = proj@feat-x ] +} + +@test "a worktree outside the repo is still repo@worktree" { + git -C "$REPO" worktree add -q "$BATS_TEST_TMPDIR/elsewhere" -b other + cd "$BATS_TEST_TMPDIR/elsewhere" + run sh "$BIN" + [ "$status" -eq 0 ] + [ "$output" = proj@elsewhere ] +} + +@test "tabname.worktree-format changes the worktree label, non-ASCII included" { + git -C "$REPO" worktree add -q "$REPO/.claude/worktrees/feat-x" -b feat-x + git config --global tabname.worktree-format '{repo} ⎇ {worktree}' + run sh "$BIN" "$REPO/.claude/worktrees/feat-x" + [ "$status" -eq 0 ] + [ "$output" = 'proj ⎇ feat-x' ] +} + +@test "an empty tabname.worktree-format falls back to the default" { + git -C "$REPO" worktree add -q "$REPO/.claude/worktrees/feat-x" -b feat-x + git config --global tabname.worktree-format '' + run sh "$BIN" "$REPO/.claude/worktrees/feat-x" + [ "$status" -eq 0 ] + [ "$output" = proj@feat-x ] +} + +@test "outside any repo: nothing, exit 1" { + mkdir "$BATS_TEST_TMPDIR/plain" + cd "$BATS_TEST_TMPDIR/plain" + run sh "$BIN" + [ "$status" -eq 1 ] + [ -z "$output" ] +} + +@test "inside the .git directory: nothing, exit 1" { + cd "$REPO/.git" + run sh "$BIN" + [ "$status" -eq 1 ] + [ -z "$output" ] +} + +@test "no git on PATH: nothing, exit 1" { + mkdir "$BATS_TEST_TMPDIR/empty" + cd "$REPO" + PATH="$BATS_TEST_TMPDIR/empty" run /bin/sh "$BIN" + [ "$status" -eq 1 ] + [ -z "$output" ] +} + +@test "a control character in a directory name is stripped" { + make_repo "$BATS_TEST_TMPDIR/we"$'\t'"ird" + run sh "$BIN" "$BATS_TEST_TMPDIR/we"$'\t'"ird" + [ "$status" -eq 0 ] + [ "$output" = weird ] +}