From 40d6d9e6843fa8b61285f8f5919967efb4ab08c1 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 17:10:01 -0500 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=8F=B7=EF=B8=8F?= =?UTF-8?q?=20tabname,=20the=20label=20a=20tab=20shows=20for=20a=20directo?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prints the git toplevel's name, or repo@worktree for a linked worktree (format overridable via tabname.worktree-format). Nothing and exit 1 outside a work tree. A script so the claude() wrapper and an Alfred switcher can share one answer. Co-Authored-By: Claude Opus 5.5 --- bin/executable_tabname | 60 ++++++++++++++++++++++ tests/chezmoi.bats | 1 + tests/tabname.bats | 112 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100755 bin/executable_tabname create mode 100644 tests/tabname.bats diff --git a/bin/executable_tabname b/bin/executable_tabname new file mode 100755 index 00000000000..76f321684fb --- /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 claude() wrapper titling its tab, 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/tests/chezmoi.bats b/tests/chezmoi.bats index c3022cc2eae..99101188415 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/tabname.bats b/tests/tabname.bats new file mode 100644 index 00000000000..22acfaa2eaa --- /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 ] +} From 5bb0202226014379bac591661bf34c95470c08cb Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 17:12:47 -0500 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=8F=B7=EF=B8=8F?= =?UTF-8?q?=20claude()=20titles=20the=20tab=20with=20the=20repo=20and=20si?= =?UTF-8?q?lences=20Claude=20Code's=20own=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iTerm2's Session Status row is named by the session name, which an OSC 0 title sets. Claude Code writes its rename/AI/haiku title there, so rows read as conversation topics. The wrapper now exports CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 (off-spellings normalised to unset, so one launch can get Claude's titles back) and writes one title itself before exec: the tabname label, prefixed by WORKTABS_GROUP, or the WORKSET name. Only an attended launch gets the bytes; the attended test is hoisted out of the second-session question and shared. Co-Authored-By: Claude Opus 5.5 --- dot_functions | 51 ++++++++++++++++++++----- tests/claude-profiles.bats | 77 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/dot_functions b/dot_functions index da0e1a34359..023ab146b2a 100644 --- a/dot_functions +++ b/dot_functions @@ -349,21 +349,24 @@ 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 label tn; 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. Both the second-session question and the tab title + # below are asked of attended launches only. + 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 +432,26 @@ 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 keeps Claude Code from titling + # the tab itself. iTerm2's Session Status row is named by the + # session's name, which an OSC 0 title sets; Claude Code writes its + # rename/AI/haiku title there and clears it on exit, so a row reads + # as a conversation topic, not a repo. With the variable set it + # writes nothing, and the one title the wrapper writes below stands + # for the whole session until precmd restores the path at the next + # prompt. 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; the cc-status hooks keep + # painting the row either way. Claude Code reads it as a raw string, + # so `0` and `false` would still disable — those spellings, and the + # empty string, are normalised to unset, which is how one launch gets + # Claude's own titles back. If they are back, the tab is Claude's + # and no title is written. The title goes to fd 2 and only for an + # attended launch: a `-p` run or a pipe must never receive escape + # bytes. A working set (WORKSET) is N repos, so the set names the tab; + # worktabs' group, when there is one, prefixes it as precmd does. No + # label, no write: an empty title would make iTerm2 show its profile + # name. # 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 +462,14 @@ 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-)}"; + 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; + if [[ -n $attended && -n ${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-} && $TERM != dumb ]] && tn="$(whence -p tabname)"; then + label=${WORKSET:-$("$tn" 2>/dev/null)}; + [[ -n $label ]] && print -rn -- $'\e]0;'"${WORKTABS_GROUP:+[$WORKTABS_GROUP] }$label"$'\a' >&2; + fi; exec command claude "$@"; ) } diff --git a/tests/claude-profiles.bats b/tests/claude-profiles.bats index 3dd89548ee1..51d93fd4ad0 100644 --- a/tests/claude-profiles.bats +++ b/tests/claude-profiles.bats @@ -31,7 +31,10 @@ 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 WORKTABS_GROUP WORKSET + # The title write is skipped for TERM=dumb, which CI shells may carry. + export TERM=xterm # Fixture pins: one "work" org and one "personal" owner, mapped to two # accounts; work maps to the default dir, personal to a separate profile. @@ -46,6 +49,8 @@ setup() { printf '#!/bin/sh\necho "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-UNSET}"\n' \ > "$BATS_TEST_TMPDIR/bin/claude" chmod +x "$BATS_TEST_TMPDIR/bin/claude" + # The real label script, so the title the wrapper writes is the real one. + ln -s "$BATS_TEST_DIRNAME/../bin/executable_tabname" "$BATS_TEST_TMPDIR/bin/tabname" if [ -n "$REAL_PYTHON3" ]; then printf '#!/bin/sh\nexec "%s" "$@"\n' "$REAL_PYTHON3" > "$BATS_TEST_TMPDIR/bin/python3" chmod +x "$BATS_TEST_TMPDIR/bin/python3" @@ -325,6 +330,34 @@ PY [ "$output" = "SUBAGENT=opus" ] } +@test "claude: Claude Code's own terminal titles are off 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=1" ] +} + +@test "claude: an empty, 0, false, no or off value leaves Claude's titles on" { + # Claude Code reads the variable as a raw string, so any non-empty value + # disables titles; 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" + 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: any other set terminal-title value is kept" { + 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=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 @@ -805,13 +838,14 @@ make_session() { # Run `claude "$@"` in $PTY_DIR on a pseudo-terminal — the question is only # asked of a launch somebody is sitting at — and answer it with $PTY_ANSWER, -# typed ahead so a launch that asks nothing simply leaves it unread. A +# typed ahead so a launch that asks nothing simply leaves it unread. PTY_ENV +# is an optional `NAME=value` prefix for the launch. A # hand-rolled fork-and-read rather than the obvious tools: zsh's zpty returns # short reads on macOS, and pty.spawn before Python 3.10 never returns there # once the child exits. pty_claude() { [ -n "$REAL_PYTHON3" ] || skip "python3 not installed" - printf "source '%s'; cd '%s'; claude %s; print rc=\$?\n" "$DOTFUNCTIONS" "$PTY_DIR" "$*" \ + printf "source '%s'; cd '%s'; %s claude %s; print rc=\$?\n" "$DOTFUNCTIONS" "$PTY_DIR" "${PTY_ENV-}" "$*" \ > "$BATS_TEST_TMPDIR/pty-inner.zsh" run python3 -c ' import os, pty, sys @@ -930,3 +964,40 @@ os.waitpid(pid, 0) [[ "$output" != *"already running"* ]] [[ "$output" == *"rc=0"* ]] } + +@test "claude: an attended launch titles the tab with the repo before exec" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" == *$'\e]0;repo\a'* ]] + [[ "$output" == *"rc=0"* ]] +} + +@test "claude: a workspace group prefixes the title and a working set names it" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + PTY_ENV='WORKTABS_GROUP=personal' PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" == *$'\e]0;[personal] repo\a'* ]] + PTY_ENV='WORKSET=myset' PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" == *$'\e]0;myset\a'* ]] + PTY_ENV='WORKTABS_GROUP=personal WORKSET=myset' PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" == *$'\e]0;[personal] myset\a'* ]] +} + +@test "claude: a linked worktree is titled repo@worktree" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + git -C "$repo" -c user.name=octo -c user.email=octo@example.invalid commit -q --allow-empty -m init + git -C "$repo" worktree add -q "$repo/.claude/worktrees/feat-x" -b feat-x + PTY_DIR=$repo/.claude/worktrees/feat-x PTY_ANSWER=n pty_claude + [[ "$output" == *$'\e]0;repo@feat-x\a'* ]] +} + +@test "claude: no title for -p, with Claude's titles left on, or outside a repo" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + PTY_DIR=$repo PTY_ANSWER=n pty_claude -p hello + [[ "$output" != *$'\e]0;'* ]] + PTY_ENV='CLAUDE_CODE_DISABLE_TERMINAL_TITLE=' PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" != *$'\e]0;'* ]] + mkdir "$BATS_TEST_TMPDIR/plain" + PTY_DIR=$BATS_TEST_TMPDIR/plain PTY_ANSWER=n pty_claude + [[ "$output" != *$'\e]0;'* ]] + [[ "$output" == *"rc=0"* ]] +} From b48fe02a0a3efe5313ace4996c6af47e712e5645 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 17:18:13 -0500 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=8F=B7=EF=B8=8F?= =?UTF-8?q?=20the=20cc-status=20shim=20stops=20repeating=20the=20repo=20in?= =?UTF-8?q?=20every=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claude() wrapper now titles the tab with the repo, and the row name is that title, so prefixing each Session Status detail with the cwd's basename only spent room. The detail carries the status alone. A session launched without the wrapper keeps Claude's own title as its row name. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +-- .../cc-status-shim/executable_cc-status | 10 ++--- tests/cc-status-shim.bats | 41 ++++++++++--------- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ad1c4a0b8ce..1ad6cd6b951 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1660,9 +1660,9 @@ 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 is the tab title, which the `claude` wrapper +sets to the repo (see the title paragraph below). A turn that **ends by asking the operator a question** paints `waiting` in yellow (`tabstatus.color.question`, default `#ffd75f`) with the question as diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status index f6a228ad937..a28fae7b42b 100755 --- a/dot_local/libexec/cc-status-shim/executable_cc-status +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -39,9 +39,10 @@ # ${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 is the session's tab title; the claude +# wrapper titles it with the repo (tabname), so the detail carries only the +# status. A session launched without the wrapper keeps Claude's own title as its +# row name, and its detail no longer names the repo. # # 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 @@ -230,7 +231,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_-]+") @@ -273,7 +273,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, diff --git a/tests/cc-status-shim.bats b/tests/cc-status-shim.bats index b4d7426c591..b52b751bb69 100644 --- a/tests/cc-status-shim.bats +++ b/tests/cc-status-shim.bats @@ -303,7 +303,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 +311,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 +353,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 ] } @@ -396,7 +396,7 @@ PY [ "$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" ] + [ "$(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 +406,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" { @@ -422,7 +422,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" { @@ -441,10 +441,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" { @@ -470,12 +471,12 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi [ "$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" ] + [ "$(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" { @@ -497,12 +498,12 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi 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" ] + [ "$(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 +523,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?')" @@ -551,7 +552,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 +563,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 +572,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 +599,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 +618,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 +636,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)" From 81e032aad3ea58ac16b3b6ce9135b6dea2bd3ad5 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 22:06:43 -0500 Subject: [PATCH 4/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20claude()=20no=20longer=20writes=20a=20t?= =?UTF-8?q?ab=20title,=20only=20keeps=20Claude's=20own=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iTerm2 3.7.5+'s Claude Code workgroup renames the session to "Chat" on job start, so a title written before exec is overwritten within a second. The cc-status shim will name the session after that; the wrapper keeps the CLAUDE_CODE_DISABLE_TERMINAL_TITLE default so Claude Code's own OSC 0 titles do not compete, and loses the OSC 0 write and its pty tests. Co-Authored-By: Claude Opus 5.5 --- dot_functions | 40 +++++++++++-------------------- tests/claude-profiles.bats | 48 +++----------------------------------- 2 files changed, 16 insertions(+), 72 deletions(-) diff --git a/dot_functions b/dot_functions index 023ab146b2a..3bb84a12efb 100644 --- a/dot_functions +++ b/dot_functions @@ -349,14 +349,13 @@ 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 attended label tn; + 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. Both the second-session question and the tab title - # below are asked of attended launches only. + # 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 @@ -432,26 +431,17 @@ 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 keeps Claude Code from titling - # the tab itself. iTerm2's Session Status row is named by the - # session's name, which an OSC 0 title sets; Claude Code writes its - # rename/AI/haiku title there and clears it on exit, so a row reads - # as a conversation topic, not a repo. With the variable set it - # writes nothing, and the one title the wrapper writes below stands - # for the whole session until precmd restores the path at the next - # prompt. 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; the cc-status hooks keep - # painting the row either way. Claude Code reads it as a raw string, - # so `0` and `false` would still disable — those spellings, and the - # empty string, are normalised to unset, which is how one launch gets - # Claude's own titles back. If they are back, the tab is Claude's - # and no title is written. The title goes to fd 2 and only for an - # attended launch: a `-p` run or a pipe must never receive escape - # bytes. A working set (WORKSET) is N repos, so the set names the tab; - # worktabs' group, when there is one, prefixes it as precmd does. No - # label, no write: an empty title would make iTerm2 show its profile - # name. + # CLAUDE_CODE_DISABLE_TERMINAL_TITLE keeps Claude Code from writing its + # own OSC 0 tab title (rename/AI/haiku). The wrapper writes none: on + # iTerm2 3.7.5+ the Claude Code workgroup renames the session on job + # start, so the cc-status shim renames it to the repo (tabname) after + # that, and Claude's titles would only compete with it. 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 — those spellings, `no`, `off` and the empty string are + # normalised to unset, which is how one launch gets Claude's own titles + # back. # 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 @@ -466,10 +456,6 @@ function claude() { 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; - if [[ -n $attended && -n ${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-} && $TERM != dumb ]] && tn="$(whence -p tabname)"; then - label=${WORKSET:-$("$tn" 2>/dev/null)}; - [[ -n $label ]] && print -rn -- $'\e]0;'"${WORKTABS_GROUP:+[$WORKTABS_GROUP] }$label"$'\a' >&2; - fi; exec command claude "$@"; ) } diff --git a/tests/claude-profiles.bats b/tests/claude-profiles.bats index 51d93fd4ad0..55e76c78c3e 100644 --- a/tests/claude-profiles.bats +++ b/tests/claude-profiles.bats @@ -32,9 +32,7 @@ setup() { 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_DISABLE_TERMINAL_TITLE WORKTABS_GROUP WORKSET - # The title write is skipped for TERM=dumb, which CI shells may carry. - export TERM=xterm + 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. @@ -49,8 +47,6 @@ setup() { printf '#!/bin/sh\necho "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-UNSET}"\n' \ > "$BATS_TEST_TMPDIR/bin/claude" chmod +x "$BATS_TEST_TMPDIR/bin/claude" - # The real label script, so the title the wrapper writes is the real one. - ln -s "$BATS_TEST_DIRNAME/../bin/executable_tabname" "$BATS_TEST_TMPDIR/bin/tabname" if [ -n "$REAL_PYTHON3" ]; then printf '#!/bin/sh\nexec "%s" "$@"\n' "$REAL_PYTHON3" > "$BATS_TEST_TMPDIR/bin/python3" chmod +x "$BATS_TEST_TMPDIR/bin/python3" @@ -838,14 +834,13 @@ make_session() { # Run `claude "$@"` in $PTY_DIR on a pseudo-terminal — the question is only # asked of a launch somebody is sitting at — and answer it with $PTY_ANSWER, -# typed ahead so a launch that asks nothing simply leaves it unread. PTY_ENV -# is an optional `NAME=value` prefix for the launch. A +# typed ahead so a launch that asks nothing simply leaves it unread. A # hand-rolled fork-and-read rather than the obvious tools: zsh's zpty returns # short reads on macOS, and pty.spawn before Python 3.10 never returns there # once the child exits. pty_claude() { [ -n "$REAL_PYTHON3" ] || skip "python3 not installed" - printf "source '%s'; cd '%s'; %s claude %s; print rc=\$?\n" "$DOTFUNCTIONS" "$PTY_DIR" "${PTY_ENV-}" "$*" \ + printf "source '%s'; cd '%s'; claude %s; print rc=\$?\n" "$DOTFUNCTIONS" "$PTY_DIR" "$*" \ > "$BATS_TEST_TMPDIR/pty-inner.zsh" run python3 -c ' import os, pty, sys @@ -964,40 +959,3 @@ os.waitpid(pid, 0) [[ "$output" != *"already running"* ]] [[ "$output" == *"rc=0"* ]] } - -@test "claude: an attended launch titles the tab with the repo before exec" { - repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') - PTY_DIR=$repo PTY_ANSWER=n pty_claude - [[ "$output" == *$'\e]0;repo\a'* ]] - [[ "$output" == *"rc=0"* ]] -} - -@test "claude: a workspace group prefixes the title and a working set names it" { - repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') - PTY_ENV='WORKTABS_GROUP=personal' PTY_DIR=$repo PTY_ANSWER=n pty_claude - [[ "$output" == *$'\e]0;[personal] repo\a'* ]] - PTY_ENV='WORKSET=myset' PTY_DIR=$repo PTY_ANSWER=n pty_claude - [[ "$output" == *$'\e]0;myset\a'* ]] - PTY_ENV='WORKTABS_GROUP=personal WORKSET=myset' PTY_DIR=$repo PTY_ANSWER=n pty_claude - [[ "$output" == *$'\e]0;[personal] myset\a'* ]] -} - -@test "claude: a linked worktree is titled repo@worktree" { - repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') - git -C "$repo" -c user.name=octo -c user.email=octo@example.invalid commit -q --allow-empty -m init - git -C "$repo" worktree add -q "$repo/.claude/worktrees/feat-x" -b feat-x - PTY_DIR=$repo/.claude/worktrees/feat-x PTY_ANSWER=n pty_claude - [[ "$output" == *$'\e]0;repo@feat-x\a'* ]] -} - -@test "claude: no title for -p, with Claude's titles left on, or outside a repo" { - repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') - PTY_DIR=$repo PTY_ANSWER=n pty_claude -p hello - [[ "$output" != *$'\e]0;'* ]] - PTY_ENV='CLAUDE_CODE_DISABLE_TERMINAL_TITLE=' PTY_DIR=$repo PTY_ANSWER=n pty_claude - [[ "$output" != *$'\e]0;'* ]] - mkdir "$BATS_TEST_TMPDIR/plain" - PTY_DIR=$BATS_TEST_TMPDIR/plain PTY_ANSWER=n pty_claude - [[ "$output" != *$'\e]0;'* ]] - [[ "$output" == *"rc=0"* ]] -} From 2af68d7b84f6836b0f4dd0445946020438d7e347 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 22:20:03 -0500 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=8F=B7=EF=B8=8F?= =?UTF-8?q?=20the=20cc-status=20shim=20renames=20the=20session=20to=20the?= =?UTF-8?q?=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iTerm2 3.7.5+ the Claude Code workgroup renames the session to "Chat" when the job starts, so every Session Status row read "Chat (claude)". The shim now runs it2 session set-name with [WORKTABS_GROUP] and WORKSET or tabname of the cwd, on a session's first UserPromptSubmit (marked by .named) and on every Stop and StopFailure, never on SessionStart. It is an in-memory override; no iTerm2 pref is written. The rename runs after cc-status and before the paint, so the paint stays the last it2 call of a turn end. explain prints name= and rename=. The sh gate now sends every Stop and StopFailure to python3. tests/cc-status-shim.bats gains a tabname stub and 11 rename tests. Counts changed for Stops carrying a cwd: the seat Stop over an active seat and the question Stop with a quiet task go from 1 it2 call to 2; the seat-not-among-tasks, stale-state-file, statement-ending, active-task question and no-question-mark Stops go from "no it2 call" to exactly the rename (rename_only). Co-Authored-By: Claude Opus 5.5 --- .../cc-status-shim/executable_cc-status | 95 ++++++++-- tests/cc-status-shim.bats | 167 ++++++++++++++++-- 2 files changed, 234 insertions(+), 28 deletions(-) diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status index a28fae7b42b..0ae06f6b842 100755 --- a/dot_local/libexec/cc-status-shim/executable_cc-status +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -39,10 +39,23 @@ # ${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. The row name is the session's tab title; the claude -# wrapper titles it with the repo (tabname), so the detail carries only the -# status. A session launched without the wrapper keeps Claude's own title as its -# row name, and its detail no longer names 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) 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; 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 @@ -70,10 +83,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 on +# the first UserPromptSubmit of a session, so once per turn end; 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 @@ -140,21 +154,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":"'*) @@ -173,7 +193,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" @@ -238,6 +258,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: @@ -418,6 +439,28 @@ 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" + 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") + out = run_out([tn, cwd]) if isinstance(cwd, str) and cwd else None + clean = lambda v: re.sub(r"[\x00-\x1f\x7f]", "", v or "").strip() + name = clean(os.environ.get("WORKSET")) or clean(out) + 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 +482,9 @@ 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"cc-status={real or 'absent'}") print(f"it2={it2 or 'absent'}") sys.exit(0) @@ -447,6 +493,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 +507,17 @@ 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() +if title: + call([it2, "session", "set-name", title, "--session", sid], silent=True) + if event == "UserPromptSubmit" and nf: + try: + os.makedirs(state_dir, exist_ok=True) + open(nf, "w").close() + except OSError: + pass 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 b52b751bb69..396c3b1ca10 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" @@ -395,7 +413,7 @@ 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 ] + [ "$(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" ] } @@ -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" { @@ -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" ] } @@ -470,7 +488,7 @@ 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 ] + [ "$(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" ] } @@ -483,21 +501,21 @@ question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assi 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 ] + [ "$(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" ] } @@ -535,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" { @@ -686,3 +704,130 @@ 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" { + 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: 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"* ]] + 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)"* ]] + 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" ] +} From 7a05fcb254ad8f972c68467d61d505b2db30203a Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 22:31:57 -0500 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=93=9D=20docs:=20=F0=9F=8F=B7?= =?UTF-8?q?=EF=B8=8F=20session=20status=20rows=20are=20named=20by=20the=20?= =?UTF-8?q?shim,=20not=20by=20Claude's=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record why rows read "Chat (claude)" on iTerm2 3.7.5+ (the Claude Code workgroup renames the session on job start), that the cc-status shim renames it to the repo through it2 session set-name, and that no pref in the tracked iTerm2 plist is part of the mechanism. Also tabname's contract, the wrapper's CLAUDE_CODE_DISABLE_TERMINAL_TITLE default and its costs, and the shim's new per-turn python3 cost. The shared memory counts five wrapper defaults. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++++---- bin/executable_tabname | 2 +- dot_claude-shared/CLAUDE.md | 5 ++- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1ad6cd6b951..8a66ac83e62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1661,8 +1661,69 @@ 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. The detail carries -only the status: the row name is the tab title, which the `claude` wrapper -sets to the repo (see the title paragraph below). +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, 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; 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** — the plist's +uncommitted Workgroups blob and triggers are iTerm2's own, left alone, and +clearing the root's displayName in Settings was rejected because iTerm2 would +save that into the tracked plist. `cc-status explain` prints `name=` and +`rename=yes|no ()`. 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; it only exports +`CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` (`''`, `0`, `false`, `no` and `off` are +normalised to unset, since Claude Code reads the variable as a raw string and +would treat `0` as disabling), 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. +Without the export, outside a workgroup (another machine, integration not +installed), those titles would name the row. 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 export if it hurts; the Stop rename converges every +turn either way. 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 +1772,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 index 76f321684fb..4b5ab5a6a7d 100755 --- a/bin/executable_tabname +++ b/bin/executable_tabname @@ -10,7 +10,7 @@ # title would otherwise blank it. # # A script rather than a zsh function because more than one caller wants the -# same answer: the claude() wrapper titling its tab, and any other tab or +# 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 diff --git a/dot_claude-shared/CLAUDE.md b/dot_claude-shared/CLAUDE.md index 42135c8e589..f4e1c75808d 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 five defaults**, 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,9 @@ 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. + The fifth, `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`, stops Claude's own tab + titles so the cc-status shim's repo name stands; 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 From da5ea8c46621abf9f7259b3d9f0df8a93af118fc Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 22:42:49 -0500 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=F0=9F=8F=B7=EF=B8=8F?= =?UTF-8?q?=20the=20shim=20marks=20the=20first=20prompt=20whatever=20happe?= =?UTF-8?q?ns,=20and=20WORKSET=20needs=20no=20tabname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .named marker is now written on every first UserPromptSubmit, not only after a rename, so a session outside a repo or without tabname costs one python start rather than one per prompt. session_title() checks $WORKSET before looking for tabname: a working set names the row even with no tabname installed and outside a repo. explain gains marker=present|none. Tests: the "outside a repo, without tabname, or without it2" case stays; a new test asserts the marker for not-a-repo and no-tabname and that the second prompt skips python (no it2 call, no tabname call, payload fed unchanged); without it2 the sh gate passes before python so there is no marker. Two WORKSET tests and one explain marker test added; the explain test asserts marker=. No existing count assertion changed. CLAUDE.md: the wording that said no tabname always means no rename now excepts WORKSET, and notes the marker. The "uncommitted" plist sentence is step 8's. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 +-- .../cc-status-shim/executable_cc-status | 42 +++++++------ tests/cc-status-shim.bats | 62 ++++++++++++++++++- 3 files changed, 89 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8a66ac83e62..2148c1580cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1679,20 +1679,22 @@ 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, deleted on SessionStart/SessionEnd) and on every Stop and StopFailure, so +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; 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 +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** — the plist's uncommitted Workgroups blob and triggers are iTerm2's own, left alone, and clearing the root's displayName in Settings was rejected because iTerm2 would save that into the tracked plist. `cc-status explain` prints `name=` and -`rename=yes|no ()`. A tab already stuck on `Chat (-zsh)` is fixed once +`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. diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status index 0ae06f6b842..6980437400a 100755 --- a/dot_local/libexec/cc-status-shim/executable_cc-status +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -50,12 +50,13 @@ # --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) and on every Stop and StopFailure, just before the paint, so a later +# 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; no tabname, no -# cwd or no repo means no rename. +# 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 @@ -83,9 +84,9 @@ # paints; CPU that moved shows as "(cpu busy)" in the quiet paint. No lsof, or # a failed probe, means unknown, never stalled. # -# Cost: python3 runs on every Stop and StopFailure (the rename needs it) and on -# the first UserPromptSubmit of a session, so once per turn end; the other -# events still cost only the sh gate. A rename adds one tabname and one it2 +# 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. # @@ -449,15 +450,16 @@ def session_title(): return None, "no session id" if not it2: return None, "no it2" - 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") - out = run_out([tn, cwd]) if isinstance(cwd, str) and cwd else None clean = lambda v: re.sub(r"[\x00-\x1f\x7f]", "", v or "").strip() - name = clean(os.environ.get("WORKSET")) or clean(out) + name = clean(os.environ.get("WORKSET")) # a set names the row; no tabname needed if not name: - return None, "not a repo" + 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], "" @@ -485,6 +487,7 @@ if explain: 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) @@ -510,14 +513,15 @@ else: # 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 == "UserPromptSubmit" and nf: - try: - os.makedirs(state_dir, exist_ok=True) - open(nf, "w").close() - except OSError: - pass 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 396c3b1ca10..4b3a69157ec 100644 --- a/tests/cc-status-shim.bats +++ b/tests/cc-status-shim.bats @@ -763,6 +763,7 @@ NAMED_FILE() { printf '%s/tabstatus/s1.named' "$XDG_STATE_HOME"; } } @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 ] @@ -782,6 +783,53 @@ NAMED_FILE() { printf '%s/tabstatus/s1.named' "$XDG_STATE_HOME"; } [ "$(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)" @@ -816,13 +864,13 @@ NAMED_FILE() { printf '%s/tabstatus/s1.named' "$XDG_STATE_HOME"; } @test "explain: prints name= and rename= with the reason" { run sh "$SHIM" explain <<< "$(named_payload Stop)" - [[ $output == *"name=proj"* && $output == *"rename=yes"* ]] + [[ $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 == *"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)" @@ -831,3 +879,13 @@ NAMED_FILE() { printf '%s/tabstatus/s1.named' "$XDG_STATE_HOME"; } [ ! -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)"* ]] +} From cbc4f1adc3481acd7641d9351027503537e93e69 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 22:45:04 -0500 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=93=9D=20docs:=20=F0=9F=8F=B7?= =?UTF-8?q?=EF=B8=8F=20CLAUDE.md=20says=20what=20iTerm2's=20onboarding=20w?= =?UTF-8?q?rites,=20not=20what=20this=20clone=20holds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab-name paragraph described "the plist's uncommitted Workgroups blob", which is a fact about one Mac's working tree. Say instead that the Claude Code onboarding writes the workgroup and its Enter/Exit triggers into the plist itself. The step-7 wording fix ($WORKSET needs no tabname) was already in. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2148c1580cf..80ac390ca76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1690,10 +1690,12 @@ at 80 characters. `$WORKSET` needs no `tabname`; without it, no `tabname`, no `c 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** — the plist's -uncommitted Workgroups blob and triggers are iTerm2's own, left alone, and -clearing the root's displayName in Settings was rejected because iTerm2 would -save that into the tracked plist. `cc-status explain` prints `name=` and +`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 From 9c401a98cbaf53a7de227d30e869ce72fa550a9a Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 15 Oct 2026 22:57:43 -0500 Subject: [PATCH 9/9] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=8F=B7=EF=B8=8F?= =?UTF-8?q?=20claude()=20exports=20the=20title=20opt-out=20only=20where=20?= =?UTF-8?q?claude.terminal-title=20is=20false?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 was exported on every machine, but it only pays where the cc-status shim, iTerm2 3.7.5+ and the integration are all present. Gate it on machine-local git config, default off, so a public-only clone or a Linux box keeps Claude's own titles. claude-doctor gains a titles: line. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 37 ++++++++++++++++++--------------- docs/private-overlays.md | 16 +++++++++++++++ dot_claude-shared/CLAUDE.md | 10 +++++---- dot_functions | 38 ++++++++++++++++++++-------------- tests/claude-profiles.bats | 41 +++++++++++++++++++++++++++++++++---- 5 files changed, 103 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 80ac390ca76..45e01995ed4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1712,22 +1712,27 @@ default is ASCII because the toolbelt renders rows in iTerm2's UI font 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; it only exports -`CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` (`''`, `0`, `false`, `no` and `off` are -normalised to unset, since Claude Code reads the variable as a raw string and -would treat `0` as disabling), 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. -Without the export, outside a workgroup (another machine, integration not -installed), those titles would name the row. 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 export if it hurts; the Stop rename converges every -turn either way. 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. +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 diff --git a/docs/private-overlays.md b/docs/private-overlays.md index f02f0cbb954..adda838ec2b 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 f4e1c75808d..5cb531a06ba 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 five 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,9 +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. - The fifth, `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`, stops Claude's own tab - titles so the cc-status shim's repo name stands; set it to `''`, `0`, `false`, - `no` or `off` for one launch to get them back. + 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 3bb84a12efb..5b0b0451ae5 100644 --- a/dot_functions +++ b/dot_functions @@ -431,17 +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 keeps Claude Code from writing its - # own OSC 0 tab title (rename/AI/haiku). The wrapper writes none: on - # iTerm2 3.7.5+ the Claude Code workgroup renames the session on job - # start, so the cc-status shim renames it to the repo (tabname) after - # that, and Claude's titles would only compete with it. 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 — those spellings, `no`, `off` and the empty string are - # normalised to unset, which is how one launch gets Claude's own titles - # back. + # 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 @@ -452,10 +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-)}"; - 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; + 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 "$@"; ) } @@ -673,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/tests/claude-profiles.bats b/tests/claude-profiles.bats index 55e76c78c3e..584cd3f6dea 100644 --- a/tests/claude-profiles.bats +++ b/tests/claude-profiles.bats @@ -168,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). @@ -326,19 +336,41 @@ PY [ "$output" = "SUBAGENT=opus" ] } -@test "claude: Claude Code's own terminal titles are off by default" { +@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: an empty, 0, false, no or off value leaves Claude's titles on" { +@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; the wrapper turns the off-spellings into unset. + # 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 ] @@ -346,9 +378,10 @@ PY done } -@test "claude: any other set terminal-title value is kept" { +@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" ]