Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 78 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1660,9 +1660,79 @@ Notification through, since waiting outranks a colour. Running seats are
tracked in `${XDG_STATE_HOME:-~/.local/state}/tabstatus/<session_id>.seats`
— written on SubagentStart, pruned on SubagentStop, deleted on
SessionStart/SessionEnd, ignored past a day — so a Stop over a still-running
background seat repaints its colour, not orange. Every detail the shim
writes starts with the repo (the payload cwd's basename), because the row
name is the tab title, often a task title.
background seat repaints its colour, not orange. The detail carries
only the status: the row name carries the repo (next paragraph).

**The row's name is the session's name, and the shim sets it.** The Session
Status tool labels a row with `session.name` (iTerm2's `ToolStatus.swift`),
which the tab shows too. On iTerm2 3.7.5+ the Claude Code integration's
onboarding leaves a `builtin.claudeCode` workgroup (root session displayName
"Chat", peer "Diff") and, on every profile, an Enter Workgroup trigger that
fires when a job named `claude` starts. Entering renames the leader session to
the root's displayName through a `KEY_NAME` override — that is why every row
read `Chat (claude)`. The `(claude)` is the Job component of iTerm2's job-only
default title (these profiles set no `Title Components`) and is not touched
here. The workgroup's exit trigger restores the shell's own title when
`claude` quits.

So the shim renames the session back: `it2 session set-name` with `[<group>] `
(`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 `<session_id>.named` beside the seats
file, written whether or not a rename happened, deleted on
SessionStart/SessionEnd) and on every Stop and StopFailure, so
a later rename by iTerm2 heals at the end of the turn. It runs after the
cc-status call and before the paints, which keeps a paint the last `it2` call of
a turn end. Never on SessionStart: that fires before the job-start trigger may
have, and a rename landing first would be captured as the "original" title the
workgroup restores on exit. The title has control characters dropped and is cut
at 80 characters. `$WORKSET` needs no `tabname`; without it, no `tabname`, no `cwd`,
or a directory outside a repo means no rename, never an empty one (iTerm2 would
show its profile name). `set-name` is
a session-specific in-memory override: **no pref in
`com.googlecode.iterm2.plist` is part of this mechanism** — iTerm2's Claude
Code onboarding writes its workgroup (a `Workgroups` entry, `builtin.claudeCode`)
and the Enter/Exit Workgroup triggers into that plist itself, as it does every
other preference; that is iTerm2 saving, not part of this mechanism, and
clearing the root's displayName in Settings was rejected because it would be a
preference edit in the tracked plist. `cc-status explain` prints `name=` and
`rename=yes|no (<reason>)` 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 `<repo> (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} <glyph>{worktree}'`; the
default is ASCII because the toolbelt renders rows in iTerm2's UI font
(`NSFont.it_toolbelt()`), where a Nerd Font private-use glyph is a tofu box
unless proven otherwise. Set a glyph only after checking it in both the Session
Status tool and the tab bar. Tests: `tests/tabname.bats`.

The `claude()` wrapper writes no title. Where `git config claude.terminal-title
false` is set (machine-local, from the overlay that owns the Mac whose rows the
shim names; default unset, so a public-only clone or a Linux box is untouched)
it exports `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` (`''`, `0`, `false`, `no` and
`off` are normalised to unset under the gate, since Claude Code reads the
variable as a raw string and would treat `0` as disabling; outside the gate a
caller's value passes through untouched), so Claude Code's own OSC 0 titles —
its `/rename`-or-`-n` title, an AI title, a small-model title, and an empty one
on exit — never displace the repo between a title change and the next Stop. It
is gated rather than a default because it costs every machine and pays only
where the shim, iTerm2 3.7.5+ and the integration are all present. Two
documented costs: the docs say the variable also skips the request that
generates an AI session title, so `/resume` rows may show a summary or first
prompt instead (the binary gates that skip behind a feature flag, so it may not
happen), and it turns off Claude Code's OSC 7501 status reports, which the hooks
make redundant. Open `/resume` after a week and drop the config line if it
hurts; the Stop rename converges every turn either way. `claude-doctor`'s
`titles:` line shows the key and the variable. A session not launched through
the wrapper (bare `command claude`, Claude Desktop, an editor terminal) is
still renamed by the shim, but Claude's own titles may displace the name until
the next Stop.

A turn that **ends by asking the operator a question** paints `waiting` in
yellow (`tabstatus.color.question`, default `#ffd75f`) with the question as
Expand Down Expand Up @@ -1711,11 +1781,11 @@ and exits 0 at once without the binary, which beats a hook entry that
errors on every event.

Pass-through events cost one `sh`, a `cat`, two `case` matches and a pipe
into cc-status; a Stop with background tasks, or whose final message contains a `?` (most
Stops), adds one `python3` (~30 ms), a
`stat` per task, one `git config --get-regexp`, and an extra `it2` call only
when nothing is active; an in-seat tool call makes one `it2` call instead of
cc-status's. It fails open to stock behaviour everywhere (no binary, `it2`,
into cc-status; every Stop and StopFailure, and a session's first
UserPromptSubmit, adds one `python3` (~30 ms), a `tabname` and an `it2` start
for the rename (after the turn), a `stat` per task, one `git config
--get-regexp`, and a paint `it2` call only when nothing is active; an in-seat
tool call makes one `it2` call instead of cc-status's. It fails open to stock behaviour everywhere (no binary, `it2`,
python3 or session id, bad JSON, `it2` past five seconds) and never exits
non-zero: the row is cosmetic and must never cost a turn. It cannot see task
types that keep no output file (`monitor`, `in_process_teammate`,
Expand Down
60 changes: 60 additions & 0 deletions bin/executable_tabname
Original file line number Diff line number Diff line change
@@ -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 "<repo>@<worktree>", so the
# several worktrees of one repo stay told apart. Outside a git work tree
# (not a repo, inside a .git directory, no git) it prints nothing and exits 1
# — never 0 with empty output, since a caller that writes the label as a
# title would otherwise blank it.
#
# A script rather than a zsh function because more than one caller wants the
# same answer: the cc-status shim naming a session, and any other tab or
# session labeller (an Alfred switcher) that runs it as ~/bin/tabname <dir>.
#
# "Worktree" means a linked one, made by `git worktree add`. A submodule is not
# one: its git dir sits under <super>/.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} <glyph>{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
} <<EOF_PATHS
$out
EOF_PATHS
[ -n "$top" ] && [ -n "$common" ] || exit 1

label=${top##*/}
parent=${common%/*}
if [ "${common##*/}" = .git ] && [ "$parent" != "$top" ]; then
repo=${parent##*/}
worktree=${top##*/}
fmt=$(git -C "$dir" config --get tabname.worktree-format 2>/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"
16 changes: 16 additions & 0 deletions docs/private-overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion dot_claude-shared/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ employer-specific — profile-specific memory belongs in that profile's own
by hand or by `/model`, `/effort`, `/config` lasts only until the next apply
— make the change in the overlay source and apply it. Cross-profile defaults
go in the wrapper instead, because an overlay can only speak for one profile.
- **The wrapper exports four defaults**, each overridable per launch:
- **The wrapper exports four defaults** (and one gated export, below), each overridable per launch:
`CLAUDE_CODE_SUBAGENT_MODEL=sonnet`; `CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h`
and `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` (subagents default to a five-minute
cache, and a session on usage credits drops the main conversation to five
Expand All @@ -367,6 +367,11 @@ employer-specific — profile-specific memory belongs in that profile's own
quote: it is not this machine, this model, or this work, and by the rule
above nothing here is a saving until `/usage` moves. It injects itself into
matching subagents, so its rules are never pasted into a definition.
A fifth, `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`, is not a default: it is
exported only where `claude.terminal-title false` is set (machine-local,
overlay-supplied: the Mac whose Session Status rows the shim names), so
Claude's own tab titles do not displace the repo name; there, set it to `''`,
`0`, `false`, `no` or `off` for one launch to get them back.
- **Cache discipline.** Documented invalidators: switching model, changing
effort (except Fable 5.1 on a subscription or API key, where effort changes
keep the cache), fast mode, MCP connect or disconnect, plugin enable or
Expand Down
45 changes: 35 additions & 10 deletions dot_functions
Original file line number Diff line number Diff line change
Expand Up @@ -349,21 +349,23 @@ function bin_trace() {
# or a literal directory. With no mapping at all, this is plain `claude` on the
# default ~/.claude config dir.
function claude() {
local dir alias opener here line;
local dir alias opener here line attended;
local -a sess;
dir="$(claude_profile_dir)" || dir='';
# An attended launch: somebody is sitting at it. No terminal, print
# mode, a version/help flag, or a first argument shaped like a
# subcommand (`claude mcp …` — the shape, because a list of them would go
# stale) is not one.
if [[ -t 0 && -t 2 && ! $1 =~ '^[a-z][a-z-]*$' ]] && (( ! ${argv[(I)(-p|--print|-h|--help|-v|--version)]} )); then attended=1; fi;
# A second session in a directory that already has one puts two writers
# on the same tree, and nothing about a fresh prompt says the first is
# there. So say so, and ask — a warning with a way past it, never a
# refusal. Only a launch somebody is sitting at is asked: no terminal,
# print mode, a version/help flag, or a first argument shaped like a
# subcommand (`claude mcp …` — the shape, because a list of them would go
# stale) all pass untouched, so hooks, pipes and headless callers never
# block on a question nobody can answer. A helper that fails or finds
# nothing launches as before. Running several sessions in one checkout
# on purpose is a workflow, not a mistake: `git config
# claude.second-session-check false` turns the question off.
if [[ -t 0 && -t 2 && ! $1 =~ '^[a-z][a-z-]*$' ]] && (( ! ${argv[(I)(-p|--print|-h|--help|-v|--version)]} )) && [[ "$(command git config --get claude.second-session-check 2>/dev/null)" != false ]]; then
# refusal. Only an attended launch is asked, so hooks, pipes and
# headless callers never block on a question nobody can answer. A helper
# that fails or finds nothing launches as before. Running several
# sessions in one checkout on purpose is a workflow, not a mistake: `git
# config claude.second-session-check false` turns the question off.
if [[ -n $attended ]] && [[ "$(command git config --get claude.second-session-check 2>/dev/null)" != false ]]; then
here="$(claude_sessions_here $dir 2>/dev/null)";
if [[ -n $here ]]; then
print -ru2 -- "claude: already running in $PWD:";
Expand Down Expand Up @@ -429,6 +431,21 @@ function claude() {
# PONYTAIL_SUBAGENT_MATCHER scopes the ponytail plugin's ruleset, when
# that plugin is installed, to the seats that write code — a read-only
# scout has nothing to keep small.
# CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 is not a default: it is exported
# only where `git config claude.terminal-title false` says so. It keeps
# Claude Code from writing its own OSC 0 tab title (rename/AI/haiku),
# which would compete with the cc-status shim's rename of the Session
# Status row, and that rename exists only where iTerm2 3.7.5+, its
# Claude Code integration's workgroup trigger and the overlay-wired
# hooks are all present. So it is off until machine-local config turns
# it on, and a public-only clone or a Linux box keeps Claude's titles.
# The docs say it also skips the request that generates an AI session
# title (so /resume rows may show a summary instead) and turns off the
# OSC 7501 status reports. Claude Code reads it as a raw string, so `0`
# and `false` would still disable: under the gate those spellings, `no`,
# `off` and the empty string are normalised to unset, which is how one
# launch gets Claude's own titles back. Outside the gate the wrapper has
# no opinion and a caller's value passes through untouched.
# These live here and not in each profile's settings.json because each
# of those files is owned by that profile's private overlay, and a
# default meant for every profile would have to be repeated in each
Expand All @@ -439,6 +456,12 @@ function claude() {
export CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL="${CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL:-1h}";
export CLAUDE_CODE_PROMPT_CACHE_TTL="${CLAUDE_CODE_PROMPT_CACHE_TTL:-1h}";
export PONYTAIL_SUBAGENT_MATCHER="${PONYTAIL_SUBAGENT_MATCHER:-^(work-|unstick-)}";
if [[ "$(command git config --get claude.terminal-title 2>/dev/null)" == false ]]; then
case ${CLAUDE_CODE_DISABLE_TERMINAL_TITLE-unset} in
unset) export CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1;;
''|0|[Ff][Aa][Ll][Ss][Ee]|[Nn][Oo]|[Oo][Ff][Ff]) unset CLAUDE_CODE_DISABLE_TERMINAL_TITLE;;
esac;
fi;
exec command claude "$@";
)
}
Expand Down Expand Up @@ -656,6 +679,8 @@ function claude-doctor() {
fi;
print -r -- "mapping: ${map:-<none>}";
print -r -- "env: CLAUDE_PROFILE=${CLAUDE_PROFILE-<unset>} CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-<unset>} BROWSER=${BROWSER-<unset>} OPEN_AS_ALIAS=${OPEN_AS_ALIAS-<unset>}";
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";
Expand Down
Loading
Loading