feat(tmux): open Claude Code in a popup on prefix + a - #319
Conversation
The nvim integration gives you Claude coupled to an editing session — buffers,
selection, diagnostics, diffs. This is the other half: Claude from a shell pane,
mid-rebase, or in a directory with no editor open. Same shape as the prefix + g
lazygit popup, rooted at #{pane_current_path}, gated on the `claude` binary the
way tmux-sesh.sh gates on `sesh` (absent, it puts the reason on the status line
rather than doing a silent nothing).
NOT `display-popup -E claude`, the way lazygit is bound. lazygit is stateless so
killing its popup costs nothing; a conversation is not, and dismissing a raw
`-E claude` popup would take the thread with it. This follows tmux-scratch.sh
instead — a persistent detached session the popup attaches to, so prefix + a
toggles VISIBILITY rather than lifetime. It inherits that script's three
hard-won fixes: per-session detach-on-destroy on (otherwise quitting hops the
popup client onto the main session at popup dimensions and double-draws the real
terminal), the TERM repair (display-popup launches with TERM unset, so a nested
`tmux attach` dies with "terminal does not support clear"), and status off /
prefix None / key-table popup so every keystroke reaches Claude's TUI.
Sessions are keyed on the GIT ROOT rather than the cwd: every pane inside a repo
shares one conversation, which is the granularity the work actually has, and a
single global session would hand you dotfiles-Kali's thread while you sat in
dotfiles-core. The name carries a cksum hash of the full path so two repos
sharing a basename cannot collide onto one conversation — cksum rather than
md5sum/md5, which diverge between Linux and macOS and this ships to both.
lib/bootstrap-lib.sh symlinks tmux/scripts/ as a directory, so the new script
wires itself with no bootstrap change; its comment enumerated "prefix w/T/f" and
had already fallen behind `?`, so it now describes the mechanism instead.
Verified against a live tmux 3.4 server: without `claude` on PATH it exits 0 and
creates no session; with it, two different repos get two sessions while a subdir
of one reuses its session; and the created session has status=off, prefix=None,
key-table=popup, detach-on-destroy=on, is rooted at the repo, and runs `claude`.
Also caught a stray trailing underscore in the session name — `tr -c` was
converting basename's newline before the command substitution could strip it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvMkCtfJugUuxz6HCa1NSZ
There was a problem hiding this comment.
Pull request overview
Adds a persistent, repository-scoped Claude Code tmux popup on prefix + a.
Changes:
- Adds session creation, reuse, binary gating, and terminal repair.
- Wires and documents the new popup binding.
- Registers the script for distribution and bootstrap.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
tmux/tmux.conf |
Binds prefix + a to the popup. |
tmux/scripts/tmux-claude.sh |
Implements persistent Claude sessions. |
tmux/scripts/tmux-cheat.sh |
Documents the binding. |
lib/bootstrap-lib.sh |
Clarifies script-directory linking. |
core.manifest |
Registers the new script. |
CHANGELOG.md |
Records the user-visible feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ! tmux has-session -t "=$session" 2>/dev/null; then | ||
| # -P -F gives us the session id, which is a stable target even if the name is later changed. | ||
| session_id="$(tmux new-session -dP -s "$session" -c "$root" -F '#{session_id}' claude)" | ||
| # Make the nested session inert to tmux so every keystroke reaches Claude's TUI: no status | ||
| # bar, no prefix, and an empty key table. Same treatment tmux-scratch.sh gives its shell. | ||
| tmux set-option -s -t "$session_id" key-table popup | ||
| tmux set-option -s -t "$session_id" status off | ||
| tmux set-option -s -t "$session_id" prefix None | ||
| session="$session_id" | ||
| fi |
There was a problem hiding this comment.
Real bug, fixed in 551ec70. The diagnosis is exactly right, including the set -e interaction — the absence of set -e is deliberate (a failed set-option must never strand you without a popup), so the loser carried an empty session_id all the way to attach -t "".
Creation failure is now treated as success iff the exact session exists afterwards:
if [[ -n "$session_id" ]]; then
…apply the session options…
elif tmux has-session -t "=$session" 2>/dev/null; then
: # lost the race, which is a success — the winner already applied the options
else
tmux display-message "could not start a Claude session for $root"
exit 1
fiThe third branch matters beyond the race: without it, any creation failure produced a bare attach -t "" error rather than saying what went wrong.
Covered by two of the new hermetic cases (TMUX_NEW_FAILS with TMUX_HAS_ON=2 for the race, TMUX_HAS_ON=0 for a genuine failure). I mutation-tested the guard rather than trusting the green — removing the elif branch fails the race case with:
✗ tmux-claude: race path did not attach by name:
has-session…|new-session…|display-message could not start a Claude session for /tmp/repo-a|
i.e. it falls into the abort branch and never attaches, which is the pre-fix behaviour minus the error spray.
Generated by Claude Code
| # Root the conversation at the repo, falling back to the popup's cwd outside one. | ||
| root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="" | ||
| [[ -z "$root" ]] && root="$PWD" |
There was a problem hiding this comment.
Added in 551ec70 — eight hermetic cases in scripts/test-core.sh, using the same stubbed-PATH technique as the battery/netinfo block you pointed at. That block's own rationale applies verbatim here: "covered only by bash -n + shellcheck (static) … pure logic that a bad edit could break silently."
| # | Case | Asserts |
|---|---|---|
| 1 | absent claude |
display-message, no new-session |
| 2 | inside a repo | session keyed and rooted on the git root |
| 3 | outside a repo | falls back to cwd instead of erroring |
| 4 | session exists | reuses it — a second new-session would fork the conversation |
| 5 | two docs/ repos |
distinct sessions (the hash is the only thing separating them) |
| 6 | created session | key-table / status / prefix / detach-on-destroy all set |
| 7 | lost create race | still attaches to the winner's session |
| 8 | genuine failure | reports, and does not attach to an empty target |
The tmux stub logs its argv and is programmable — TMUX_HAS_ON says from which has-session call onward the session "exists" (0 = never), TMUX_NEW_FAILS makes creation fail — which is what makes 4, 7 and 8 separable without a live server.
One deviation from your suggestion: cksum is deliberately not stubbed. Case 5 is only meaningful if it exercises the real hash; stubbing it would assert that my test double distinguishes two paths, not that the script does.
Two things the tests themselves needed, both worth recording since they'd bite anyone extending this block:
- Case 1 runs with
PATHset to the stub dir alone, not stub-dir-first. Deleting theclaudestub isn't enough on a box that has a realclaudeinstalled — which this repo's CI does, so the test passed vacuously at first. - It invokes bash by absolute path, because a
PATH=… bash …prefix setsPATHbefore the command is looked up, so a barebashisn't found in the stub dir and nothing runs at all. That failure mode looked identical to "the script did nothing".
Mutation-tested rather than trusted green — removing the race-recovery branch, ignoring the git root, and dropping the path hash from the session name each produce explicit failures (3, 3 and 4 cases respectively).
make audit: 249 pass, 0 skip, 0 fail (up from 247).
Generated by Claude Code
…ally Two review findings, both real. 1. RACE. Two clients pressing prefix+a for the same repo can both see no session; the loser's `new-session` fails with "duplicate session". This script deliberately has no `set -e` (a failed set-option must never strand you without a popup), so the loser carried an EMPTY session id into every command below and sprayed tmux errors instead of opening the conversation its sibling had just created. Creation failure is now treated as success when the exact session exists afterwards, and only a genuine failure aborts — with a status-line message rather than `attach -t ""`, which produces a confusing bare tmux error. 2. COVERAGE. The routing decides WHICH conversation you get, and every branch of that was invisible to the static gates. scripts/test-core.sh gains eight hermetic cases using the same stubbed-PATH technique as the battery/netinfo block above it: absent-binary gate, git-root keying, non-repo cwd fallback, same-repo reuse, duplicate-basename hashing, the session options, the lost race, and a genuine creation failure. The `tmux` stub logs its argv and is programmable (TMUX_HAS_ON, TMUX_NEW_FAILS); `cksum` is deliberately NOT stubbed, since the duplicate-basename case only means something if it exercises the real hash. Two things the tests themselves needed. The absent-binary case runs with PATH set to the stub dir ALONE, not stub-dir-first: deleting the stub is not enough on a box that has a real `claude` installed, which this repo's CI does. And it invokes bash by absolute path, because a `PATH=… bash …` prefix sets PATH before the command is looked up, so a bare `bash` is not found in the stub dir and nothing runs at all. Mutation-tested rather than assumed: removing the race-recovery branch, ignoring the git root, and dropping the path hash from the session name each produce explicit failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvMkCtfJugUuxz6HCa1NSZ
The last of the Claude Code follow-ups (after #316 / #317 / #318).
Why
The nvim integration gives you Claude coupled to an editing session — buffers, selection, diagnostics, diffs. This is the other half: Claude from a shell pane, mid-rebase, or in a directory with no editor open. Same shape as the
prefix + glazygit popup, rooted at#{pane_current_path}.Gated on the
claudebinary the waytmux-sesh.shgates onsesh— but since a key that does silently nothing is a bug report, it puts the reason on the status line:tmux display-message "claude CLI not installed — see :checkhealth gerrrt (nvim) for the same gate"(
display-message, notecho: the popup closes the instant the script exits, so anything printed would only flash.)The design decision that matters
It is deliberately not
display-popup -E claude, the way lazygit is bound.lazygit is stateless — killing its popup costs nothing. A conversation is not. Dismissing a raw
-E claudepopup would take the thread with it, which makes it worse than useless. So this followstmux-scratch.shinstead: a persistent detached session the popup attaches to, soprefix + atoggles visibility, not lifetime. Quit Claude itself when you want the conversation gone.That inherits three fixes
tmux-scratch.shalready paid for:detach-on-destroy onTERMrepairdisplay-popuplaunches withTERMunset, so the nestedtmux attachdies with "terminal does not support clear"status off/prefix None/key-table popupOne session per repo, not one globally
Keyed on the git root, not the cwd — every pane inside a repo shares one conversation, which is the granularity the work actually has. A single global session would hand you
dotfiles-Kali's thread while you sat indotfiles-core; across a ten-repo fleet that's wrong most of the time.The name carries a
cksumhash of the full path so two repos sharing a basename (adocs/in each) can't collide onto one conversation.cksum, notmd5sum/md5— those diverge between Linux and macOS, and this file ships to both.Verification
make audit— 247 pass, 0 skip, 0 fail (up one: the new script picks up its own exec-bit/manifest checks).shellcheckclean.Exercised against a live tmux 3.4 server, not just read:
claudeon PATH → exits 0, creates no session.status=off,prefix=None,key-table=popup,detach-on-destroy=on, is rooted at the repo root, and hasclaudeas its running command.That run also caught a bug I'd otherwise have shipped: session names came out as
_popup_claude_dotfiles-core__2347584795— a stray trailing underscore, becausetr -cconvertedbasename's trailing newline before the command substitution could strip it. Switched to${root##*/}, which has no newline to launder.Wiring
core.manifestdoes need the new line here (unlike the nvim work) —tmux/scripts/is listed per-file on purpose, so an added or removed script is caught offline. Nobootstrap.shchange is needed:lib/bootstrap-lib.shsymlinkstmux/scripts/as a directory, so the script wires itself on the next sync. Its comment enumerated "prefix w/T/f" and had already fallen behind?, so it now describes the mechanism rather than a list that drifts.Also registered in
tmux-cheat.sh(prefix + ?).Generated by Claude Code