Skip to content

feat(tmux): open Claude Code in a popup on prefix + a - #319

Merged
Gerrrt merged 2 commits into
mainfrom
claude/neovim-plugin-exploration-mw39iv
Aug 1, 2026
Merged

feat(tmux): open Claude Code in a popup on prefix + a#319
Gerrrt merged 2 commits into
mainfrom
claude/neovim-plugin-exploration-mw39iv

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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 + g lazygit popup, rooted at #{pane_current_path}.

Gated on the claude binary the way tmux-sesh.sh gates on sesh — 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, not echo: 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 claude popup would take the thread with it, which makes it worse than useless. So this follows tmux-scratch.sh instead: a persistent detached session the popup attaches to, so prefix + a toggles visibility, not lifetime. Quit Claude itself when you want the conversation gone.

That inherits three fixes tmux-scratch.sh already paid for:

Fix Without it
per-session detach-on-destroy on quitting hops the popup client onto your main session at popup dimensions, clamping and double-drawing the real terminal
TERM repair display-popup launches with TERM unset, so the nested tmux attach dies with "terminal does not support clear"
status off / prefix None / key-table popup tmux intercepts keystrokes Claude's TUI needs

One 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 in dotfiles-core; across a ten-repo fleet that's wrong most of the time.

The name carries a cksum hash of the full path so two repos sharing a basename (a docs/ in each) can't collide onto one conversation. cksum, not md5sum/md5 — those diverge between Linux and macOS, and this file ships to both.

Verification

make audit247 pass, 0 skip, 0 fail (up one: the new script picks up its own exec-bit/manifest checks). shellcheck clean.

Exercised against a live tmux 3.4 server, not just read:

  • No claude on PATH → exits 0, creates no session.
  • Two different repos → two sessions; a subdir of one → reuses its session, doesn't create a third.
  • Created session reports status=off, prefix=None, key-table=popup, detach-on-destroy=on, is rooted at the repo root, and has claude as 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, because tr -c converted basename's trailing newline before the command substitution could strip it. Switched to ${root##*/}, which has no newline to launder.

Wiring

core.manifest does 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. No bootstrap.sh change is needed: lib/bootstrap-lib.sh symlinks tmux/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

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
Copilot AI review requested due to automatic review settings August 1, 2026 19:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +48 to +57
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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
fi

The 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

Comment on lines +34 to +36
# 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"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 PATH set to the stub dir alone, not stub-dir-first. Deleting the claude stub isn't enough on a box that has a real claude installed — which this repo's CI does, so the test passed vacuously at first.
  • It invokes bash by absolute path, because a PATH=… bash … prefix sets PATH before the command is looked up, so a bare bash isn'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
@Gerrrt
Gerrrt merged commit 5922c2c into main Aug 1, 2026
14 checks passed
@Gerrrt
Gerrrt deleted the claude/neovim-plugin-exploration-mw39iv branch August 1, 2026 20:42
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.

3 participants