Skip to content

fix(panel): read tmux pane titles, and an opt-in to name sessions from them - #169

Merged
hiskudin merged 6 commits into
mainfrom
feat/terminal-tab-titles
Sep 8, 2026
Merged

hiskudin merged 6 commits into
mainfrom
feat/terminal-tab-titles

Conversation

@hiskudin

@hiskudin hiskudin commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

What

Two related pieces, both about terminal tab titles.

1. tmux pane titles are now read. Tab titles were already collected for iTerm2, Terminal.app and VS Code/Cursor, but TmuxIntegration only ever set a pane id — so tmux rows showed the terminal and the path with nothing in between. #{pane_title} is the same OSC signal iTerm2 exposes as autoName, and it's what Claude Code rewrites each turn (✳ Review repository structure), so it's exactly the label those rows were missing.

2. A tab title can now name a session — behind Settings → Name from tab titles (STACKNUDGE_TAB_TITLE_NAMES, default off).

Warp and Ghostty are out of scope here.

Why the naming half is opt-in

SessionLabel's header already explained why the tab title was excluded outright: on iTerm2 a manual rename, an OSC escape and the profile name all write the same autoName, and Claude Code overwrites it every turn — so there's no way to read "the user chose this" back out. tmux is no better; #{pane_title} is the same OSC signal.

That argument is sound for a default, not for a prohibition. Plenty of people title tabs deliberately and want that name back. So it's a choice, off unless asked for, and it sits below both signals that are deliberate — a rename in the Sessions pane and a name set inside the agent still win. It only ever displaces the fallback to the project folder.

When on it applies everywhere a session is named: Sessions rows, the compact widget, event rows, banner titles, spoken nudges, and Slack DMs. All six route through two SessionLabel functions, so no surface is left inconsistent.

Design notes

No cache, unlike the AppleScript integrations' 15s. Measured on this machine: list-panes at 8.4ms vs osascript at 90ms (and that was iTerm2's not-running fast path). At the 3s poll cadence the query costs less than the staleness would — and a cached title would name the previous turn's task once it can reach a banner.

One query per distinct server. Pane ids (%4) are only unique within a tmux server, so titles can't be pooled into one flat map.

AppActivator.tmuxPath() / tmuxEnv() go private → internal rather than being duplicated. The locale half is load-bearing, not hygiene — see below.

Two things testing caught

The locale fix is required. Running the real command under env -i (as a launchd-spawned panel would), Claude's came back as literal underscores:

%   0   _   _       R   e   v   i   e   w       r   e   p   o ...

This is the same bug the focus path already hit once and documented; reusing tmuxEnv() is what avoids repeating it.

A real bug in the first cut of this PR. I compiled the actual TmuxIntegration against stubs and ran it on a live tmux server. hostNames() returned hiskiass-macbook-pro-2.local — lowercase — while tmux seeds pane titles with Hiskiass-MacBook-Pro-2.local. ProcessInfo.hostName gives the mDNS spelling; tmux uses the SystemConfiguration one. The sentinel filter never fired, so every untitled pane would have been named after the machine — and with the toggle on, that name would have titled a Slack DM.

The unit tests passed only because I'd written same-case fixtures. Now compared case-insensitively, with a regression test using the real mismatched casing. Verified end-to-end on a scratch server:

raw tmux:  %0 -> «Hiskiass-MacBook-Pro-2.local»   %1 -> «deploy pipeline»
real code: %1 -> «deploy pipeline»                 (%0 correctly filtered)

Deliberate gap

A renamed tmux window isn't picked up. The signal exists — show-options -w -t <win> automatic-rename reads back off only after a manual rename — but it costs a subprocess per window, and the #{automatic_rename} format that would give it away for free renders empty whether or not the window was renamed (verified both ways). Batching is the rule the TerminalIntegration protocol exists to enforce, so this is a comment rather than N more spawns per poll.

Testing

  • ./build.sh clean
  • make test-without-xcode634 tests, 1683 assertions, 0 failures (22 new)
  • Live-path verification via a standalone harness compiling the real TmuxIntegration against stubs, run under env -i against a real tmux server

Since review

  • Trim the spinner glyph Claude cycles into the tab title (///·) — it spans two Unicode categories, so a category-only rule still let the name churn frame by frame.
  • A Slack DM now requires STACKNUDGE_SLACK_DETAIL before it will use a tab title, since that switch is already the gate for local text on the one path that leaves the machine, and prompt frameworks put user@host: /full/path in tab titles.
  • STACKNUDGE_TAB_TITLE_NAMES documented in notify.conf.example.
  • Test coverage extended to the query contract itself after mutation testing showed five mutations — dropping #{host}, changing the delimiter, dropping -a, pooling servers, dropping the UTF-8 locale — all left the suite green.

hiskudin and others added 5 commits September 8, 2026 10:07
…om them

Tab titles were already collected for iTerm2, Terminal.app and VS Code, but
tmux only ever got a pane id — so tmux rows showed the terminal and the path
and nothing in between. #{pane_title} is the same OSC signal iTerm2 exposes as
autoName, and it is what Claude Code rewrites each turn, so it is exactly the
label those rows were missing.

Queried one list-panes per distinct server, since pane ids ("%4") only mean
anything within a server. No cache, unlike the AppleScript integrations: a
list-panes measured 8.4ms against osascript's 90ms, so at the 3s poll cadence
the query costs less than the staleness would — and a cached title would name
the previous turn's task once it can reach a banner.

tmux seeds every pane's title with the machine's hostname and only replaces it
when the program emits an OSC escape, so an untitled pane reads back as the
host, not as empty. Those are filtered, case-insensitively: tmux uses the
SystemConfiguration spelling ("Machine.local") while ProcessInfo.hostName
returns the lowercased mDNS one, so an exact match never fires and every plain
shell pane would have been labelled with the machine's name.

Separately, the tab title can now name a session, behind Settings → Name from
tab titles (STACKNUDGE_TAB_TITLE_NAMES, default off). It stays off by default
because the title is not evidence a human chose it — on iTerm2 a manual rename,
an OSC escape and the profile name all write the same value, and Claude Code
overwrites it every turn. But plenty of people do title tabs deliberately, and
this gives them that name back everywhere a session is named: Sessions rows,
the compact widget, event rows, banner titles, spoken nudges and Slack DMs.
It sits below both deliberate signals — a rename in the Sessions pane and a
name set inside the agent still win — and only ever displaces the cwd.

AppActivator's tmuxPath()/tmuxEnv() go from private to internal rather than
being duplicated. The locale half is load-bearing, not hygiene: without it tmux
renders "✳ …" as "_ …" for a launchd-spawned panel, which is the bug the focus
path already hit once.

Not done, deliberately: a renamed tmux *window*. The signal exists
(`show-options -w automatic-rename` reads "off" only after a manual rename) but
costs a subprocess per window, and the #{automatic_rename} format that would
give it away for free renders empty either way. Batching is the rule the
TerminalIntegration protocol exists to enforce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects found by review, three of them mine and one dead code I wrote
while claiming it did something.

VS Code and Cursor report an OS *window* title, not a tab title.
VSCodeIntegration fills tabName from what notify.sh captures with `get title of
first window whose title contains projectName` — "Panel.swift — stackone —
Cursor" — which names whichever file is open and changes on every editor tab
switch. With the toggle on that churning string became the session name in
banners, Slack DMs and spoken nudges, read aloud em-dashes and all. The field
was built to feed a meta chip (tabNameFallback returns nil rather than show a
UUID, "UI prefers no chip over a UUID"); this branch promoted it to a name
without checking what it holds. Excluded from naming, still shown on the row.

socketKey returned "" for an unknown server and "" was then translated back
into tmux's *default* socket — a real server, not a null target. Pane ids are
only unique within a server and "%0" exists on every one, so a session whose
TMUX didn't parse was titled from whatever pane shared its id over on the
default server. Reachable through the ordinary nested-tmux idioms (`TMUX= cmd`,
`env -u TMUX cmd`), which clear TMUX while leaving TMUX_PANE. It now returns
nil meaning "don't look up a title", matching what tabId already does with a
malformed TMUX: fall back rather than guess. The header comment claiming
callers "degrade to no title, never to a wrong one" was false and is now true.

The "only overwrite on a hit" guard was dead code. discover() seeds tabName nil
and reconcile calls live.with(tabName: live.tabName) — `with` invoked on `live`,
so `tabName ?? self.tabName` coalesces a value with itself. copy.tabName is
always nil on entry, so the guard never guarded anything, and the flicker its
comment claimed to prevent happened anyway. Assigning unconditionally is the
honest behaviour; preserving across polls would need to tell "query failed"
apart from "the title is the hostname sentinel", which parseTitles cannot do,
and that ambiguity is exactly how a cleared title would stick forever.

The Events row rendered the same tab twice once the toggle was on. The dedupe
compared two different AppleScript properties of one tab: notify.sh ships
`name of s` (composed, job suffix appended) while ITerm2Integration reads
`autoName`. They never compare equal, so a prefix test is what catches it.

Also drops PanelConfig.tabTitleNames, which nothing read and which parsed
"true" only, while the panel's own ConfigFile.bool accepts true/1/yes — a
divergence primed to bite whoever wired it up.

Not fixed, and worth naming: iTerm2's autoName falls back to the *profile* name
for a session that has never emitted an OSC title, so the toggle could name one
"Default". tmux got a sentinel filter for exactly this and iTerm2 didn't. The
fix is to read `profile name` in the same AppleScript loop and drop autoName
when they match — but iTerm2 is not installed on this machine and an untested
AppleScript change is not worth shipping on a claim. In practice an agent
session emits a title every turn, so the window is small.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more from the adversarial review, both about the tmux query.

The hostname sentinel was derived from the wrong API. tmux seeds an untitled
pane from gethostname(); ProcessInfo.hostName returns the mDNS spelling. On this
machine those differ only in case — which is why the case-insensitive compare
added earlier worked — but they are separate sources that merely coincide here.
On a Mac whose gethostname() is DHCP- or corp-DNS-assigned
("host.corp.example.com") while ProcessInfo reports "host.local", they are
different names outright and nothing is filtered: every untitled pane gets
labelled with the machine, which under the toggle names sessions and Slack DMs.

Fixed by putting #{host} in the format string and comparing per line, so the
sentinel is tmux's own answer rather than our guess at it. That also drops the
case-folding, drops the match against the bare first label (which would have
swallowed every pane a user on a machine called "orion" titled "orion"), and
tracks a rename at runtime — ProcessInfo.hostName is process-cached, so the old
sentinel went stale for the panel's whole lifetime after a scutil --set.

A wedged tmux stalled the poll queue on every poll, forever. The 2s timeout is
per socket and the calls are serial, and a hung server doesn't fail fast: it
accepts and never answers, so ProcessOutput spends the timeout plus its
SIGTERM/SIGKILL/drain waits — about 5s per server — with the session scan
latched behind it. The no-cache decision is right on the happy path (measured
again by review at 7ms) but meant that cost was paid every 3s indefinitely.
A failure now parks that socket for 30s.

Only a hang arms the backoff. A server that is merely gone exits rc=1 at once
with empty stdout, costs nothing, and must not be parked — doing so would
suppress titles for half a minute after an ordinary tmux restart. That
distinction is the reason the outcome bookkeeping is split into `titles(from:)`,
which also makes the failure path testable without a hung server.

Also fixes a test that asserted an un-lowercased hostname against a lowercased
set and passed only because this machine's hostName happens to be lowercase.
It's moot now that ProcessInfo is out of the path, and the tests are rewritten
around tmux-supplied hosts.

Review separately confirmed, with proof, three things this branch had only
asserted: a pane title can contain neither a tab nor a newline (tmux strips
them from an OSC title and rejects a select-pane -T carrying one), so the
delimiter is sound; format values are not re-expanded, so a title containing
"#(echo pwned)" is inert; and the missing lock is correct, since the type holds
no mutable state. The backoff map is the first, and takes one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mutation testing found every pure helper here well covered and everything
wiring them to tmux covered by nothing. Five mutations left the suite green:
deleting #{host} from the format string, changing the delimiter, dropping -a
from list-panes, pooling every server's panes into one flat map, and removing
the UTF-8 locale.

The worst is the format string. It and parseTitles are two halves of one
contract with a test on only one half, so deleting a field doesn't fail loudly
— parseTitles' field-count guard rejects every line and no session ever gets a
title again, silently, with 649 tests passing. The new test builds a line the
way tmux would, straight from the format the app actually sends, and pushes it
back through the real parser, so the halves can't drift apart unnoticed.

Extracted listPanesArgs so -a and the format are assertable, and apply() so the
per-server mapping is reachable from a test. apply() is the property socketKey
exists for: two servers each with a pane "%0" and different titles, each session
getting its own server's. Pooling them is a plausible-looking simplification
that hands a session another tmux's title — and under the naming toggle, another
tmux's title into a Slack DM.

Also asserts tmuxEnv() forces a UTF-8 locale. That isn't hygiene: without it
tmux renders "✳ …" as "_ …" for a launchd-spawned panel, which this project
already hit once on the focus path, and nothing would have noticed it going.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…return

The UTF-8 locale could still be deleted with the suite green: the test asserted
AppActivator.tmuxEnv() returns LC_ALL, which stays true when the call site stops
passing it. Injecting the runner makes the actual call observable, so the arg
vector and the env are asserted where they are used. Injecting the binary
resolver too keeps the test off the question of whether the runner has tmux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@StuBehan StuBehan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, and the rework since I started looking is better than what I was going to suggest. Asking tmux for #{host} instead of guessing at ProcessInfo.hostName is the right call - my box seeds panes office_dell_dock.lan off the dock's DHCP name while ProcessInfo.hostName says StackOne.local, so the old filter would never have fired here even case-folded. socketKey returning nil rather than assuming the default socket is the same instinct and I'm glad its in - TMUX= cmd leaving TMUX_PANE behind is a real idiom.

Poked at the other claims and they hold: tmux collapses a literal tab out of #{pane_title} and rejects a select-pane -T with a newline, build | tee log comes through intact, #{automatic_rename} renders empty on a never-renamed window, and the host sentinel matched exactly on a fresh pane. 651 tests / 0 failures locally.

One thing left that I'd want fixed - with the toggle on, Claude's gets read out loud as "eight spoked asterisk". Inline.

The Slack one is a question rather than a change request.

Small stuff: STACKNUDGE_TAB_TITLE_NAMES isn't in notify.conf.example and the comparable panel toggles are (STACKNUDGE_MUTE_WHEN_FOCUSED, STACKNUDGE_MUTE_DURATION_MIN, STACKNUDGE_THEME). Can you bin off the "Generated with Claude Code" line from the description too, we don't put those in here.

Comment thread panel/SessionLabel.swift
// the editor. That is fine for the meta row it was built for, and unusable
// as a name: it would churn, and it would be read aloud em-dashes and all.
// The window title stays visible in the row; it just can't title a Slack DM.
private static func tabTitle(of session: Session) -> String? {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with the toggle on, does ✳ Review repository structure end up going to say? I had a go and say "✳" gives byte-identical audio to say "eight spoked asterisk" - 1.23 seconds of it! So the nudge becomes "still waiting on eight spoked asterisk Review repository structure" 😬

expandForSpeech only splits on spaces so the glyph survives as its own word, and the reminder path at Panel.swift:2990 doesn't go through expandForSpeech at all. The comment right above here says a VS Code window title "would be read aloud em-dashes and all" - feels like thats the same problem, and excluding VS Code only fixes half of it? Claude's own title is the example in the header.

(not tmux specific either - iTerm2 was already filling tabName, so this turns on for every terminal)

Comment thread panel/Panel.swift
in: sessions.sessions,
persistence: SessionPersistence.shared)
persistence: SessionPersistence.shared,
allowTabTitle: nav.tabTitleNames)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the Slack and voice leg, and the comment on SlackDelivery.text says detail is opt-in because tool text "can carry paths, hostnames, and secrets in command lines, and this is the one path that leaves the machine".

label used to only ever be a name someone typed or the Claude sidecar name. Now its whatever the program in the pane wrote, and it goes into subject without slackIncludeDetail getting a say. Default zsh is fine (I checked, title just stays at the host sentinel after a cd) but anyone with a precmd title hook - omz, starship, most dotfiles - is putting user@host: /full/path in there.

Should the tab title be gated on slackIncludeDetail for the Slack leg specifically? Or at minimum called out in the README, because someone who left detail off on purpose is now getting local text out through a different door.

Comment thread panel/SessionLabel.swift Outdated
//
// So it can't be trusted as *intent*, but plenty of people title their tabs
// deliberately and want that name back. `allowTabTitle` is that choice, wired to
// the "Name sessions from tab titles" setting and false everywhere by default.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

row label is "Name from tab titles" (Settings.swift:79) and the README matches, this says "Name sessions from tab titles"

Review found two holes in the naming toggle.

Claude Code writes its spinner into the tab title and cycles the glyph frame by
frame — "✳ Review repository structure", then ✻, then ✽ — so adopting the title
verbatim made the session's name change on every animation tick. It is also
unspeakable: `say "✳"` is a second and a quarter of "eight spoked asterisk", and
neither route to speech saved us. VoicePhrase.expandForSpeech splits on spaces,
so the glyph survives as its own word, and the reminder leg
("Still waiting on \(label)") never calls expandForSpeech at all.

Trimmed at the ends only, so a symbol inside something a human typed survives.
The set is symbols plus whitespace plus an explicit few: the spinner spans two
Unicode categories — ✳ ✻ ✽ are So and ∗ is Sm, but the middle dot is Po — so a
category-only rule still let the name flip between "Fixing the parser" and
"· Fixing the parser" as the spinner turned. Widening to all punctuation instead
would eat the leading bracket of a title typed as "(wip) deploy".

The Slack DM now requires STACKNUDGE_SLACK_DETAIL before it will use a tab
title. SlackDelivery.text already treats that switch as the gate for local text
on the one path that leaves the machine, and a tab title is whatever the program
in the pane wrote — oh-my-zsh, starship and most prompt frameworks put
"user@host: /full/path" there through a precmd title hook. Someone who turned
detail off on purpose should not get their paths out through a different door,
so the DM falls back to the project name exactly as before the setting existed.

Also: document STACKNUDGE_TAB_TITLE_NAMES in notify.conf.example alongside the
comparable panel toggles, and fix a comment naming the setting differently from
its own Settings row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hiskudin

hiskudin commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

All four addressed — pushed in d3f2172. 656 tests, 1728 assertions, 0 failures.

The — you were right, and it's worse than pronunciation

I verified both halves of your trace: expandForSpeech splits on spaces so the glyph survives as its own word, and the reminder leg (Panel.swift:2989, "Still waiting on \(label)") never calls expandForSpeech at all. So there was no route to speech that saved us.

But the bigger problem is one neither of us said out loud: that glyph is an animation frame. Claude cycles ✳ → ✻ → ✽ as it works, so adopting the title verbatim meant the session's name changed on every spinner tick — churning in rows, in banners, and in the event↔session join. The speech was the symptom that made it visible.

So it's trimmed at adoption rather than only for speech, which fixes every surface at once.

One thing that only turned up because I wrote the test as "every frame must reduce to the same name" rather than "the glyph is gone": trimming CharacterSet.symbols isn't enough. The spinner spans two Unicode categories — ✳ ✻ ✽ are So and ∗ is Sm, but the middle dot is Po. A category-only rule still let the name flip between Fixing the parser and · Fixing the parser. The stragglers are now listed explicitly rather than widening to all punctuation, which would have eaten the leading bracket of a title typed as (wip) deploy (there's a test pinning that too).

Trimmed at the ends only, so build | tee log.txt → deploy survives intact. The cost is a deliberate leading emoji — 🚀 deploy reads as deploy — which seems worth paying to stop an animation frame becoming a name.

Slack — gated, not just documented

You're right that this is a different door. SlackDelivery.text's own comment names slackIncludeDetail as the gate for local text on "the one path that leaves the machine", and a tab title written by a precmd hook is exactly that class of text. Nothing about label used to be program-written; now it is.

So the DM now requires STACKNUDGE_SLACK_DETAIL before it will use a tab title, and falls back to the project name otherwise — i.e. someone who turned detail off deliberately gets exactly what they got before this setting existed. On-screen labels and speech are unchanged, since those don't leave the machine. Documented in the README as its own paragraph rather than a parenthetical, because it's a surprising asymmetry.

Small stuff

STACKNUDGE_TAB_TITLE_NAMES added to notify.conf.example next to the comparable toggles, comment reworded to match the row label, and the attribution line is off the description. Sorry about that one — noted for future PRs here.


Also worth flagging since it changed the diff you reviewed: I ran mutation testing over the new tests and five mutations left the suite green — deleting #{host} from the format string, changing the delimiter, dropping -a, pooling every server's panes into one map, and removing the UTF-8 locale. The format string and parseTitles were two halves of one contract with a test on only one half, so deleting a field failed silently: no session gets a title again, 649 tests passing. That's fixed with a round-trip test plus listPanesArgs/apply seams, and I re-verified each mutation is now caught.

Thanks for the office_dell_dock.lan data point in particular — that's the exact divergence I could only argue hypothetically from this machine, where the two names differ merely by case.

@StuBehan StuBehan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

The spinner catch is better than what I reported - I only traced it as far as speech, but the animation frame churning the name is the actual bug. Checked the category claim too and yeah, symbols alone doesn't do it, · is Po so it goes straight through.

The five green mutations are the bit thats stuck with me though. A format string and its parser being two halves of one contract, with a test on only one half - I'd bet thats true elsewhere in here, notify.sh especially.

656/0 locally. And the office_dell_dock.lan thing was luck rather than insight - my dock hands out a DHCP name, so the two only diverged because I happened to be plugged into it.

@hiskudin hiskudin changed the title feat(panel): read tmux pane titles, and an opt-in to name sessions from them fix(panel): read tmux pane titles, and an opt-in to name sessions from them Sep 8, 2026
@hiskudin
hiskudin merged commit 4f1399f into main Sep 8, 2026
6 checks passed
@hiskudin
hiskudin deleted the feat/terminal-tab-titles branch September 8, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants