Skip to content

feat(sessions): link tabs and focus panes under tmux - #160

Merged
StuBehan merged 8 commits into
mainfrom
feat/tmux-tab-linking
Aug 21, 2026
Merged

feat(sessions): link tabs and focus panes under tmux#160
StuBehan merged 8 commits into
mainfrom
feat/tmux-tab-linking

Conversation

@StuBehan

@StuBehanStuBehan commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Events and sessions didn't link to tabs, and focus was a no-op, when the agent runs inside tmux. This adds tmux support end to end.

Cause

tmux severs what the linkers rely on: the agent runs under the tmux server (parented to launchd, a separate subtree from the emulator), so the parent-chain walk never reaches iTerm2 — terminalApp unset, session dropped, event carries no terminal. And TERM_PROGRAM is tmux, not iTerm.app, so paths gated on it fall through, and ITERM_SESSION_ID in a pane is the tmux server's stale original pane.

Change

  • Sessions (SessionStore + TerminalIntegration): recognise the tmux server in the parent-chain walk (terminalApp = "tmux") and register a TMUX_PANE env-var conformer, giving each session a stable per-pane tabId (renames/colours/scoping key off it).
  • Events (notify.sh): recognise tmux in walk_session_chain and prefer TMUX_PANE over the stale ITERM_SESSION_ID, so events carry the same pane identity as sessions.
  • Focus (TmuxFocus + AppActivator.focusTmux): resolve the target from the agent pid's live env (TMUX socket, TMUX_PANE, LC_TERMINAL), then tmux select-window/select-pane and raise the host. Routed from all four focus sites, which previously bailed on the missing bundleID.

iTerm2 -CC

External tmux select-window doesn't surface iTerm2's native tab, and -CC tabs expose no tty to match on. The one handle iTerm2 gives is that its -CC session name mirrors the tmux pane_title, so focusTmux selects the iTerm2 session whose name equals the target pane's live title — bringing the exact tab + split forward. Two gotchas fixed along the way:

  • Match the title in Swift, not in an AppleScript literal — NSAppleScript (and system attribute) decode non-ASCII as MacRoman, mangling Claude's ✳ … titles.
  • Force a UTF-8 locale on the tmux subprocess — the launchd panel inherits no locale, so tmux display-message rendered as _, breaking the match. codex/agy (ASCII titles) masked this until it was isolated.

Not covered

  • Non--CC plain tmux switches the active pane only (no per-tab mapping to surface).
  • Host-raise is mapped for iTerm2 and Terminal.app; other LC_TERMINAL hosts get the pane select without an explicit raise.
  • Ambiguous only when two panes share an identical title.

Testing

  • swiftc -typecheck + bash -n/shellcheck clean; unit tests for TmuxFocus.parse and the env-var parser; CI green.
  • Verified live under iTerm2 -CC: Sessions-tab and event focus land on the correct tab/split for Claude, codex, and agy. A STACKNUDGE_PANEL_DEBUG switch logs the focus resolution (pane / title / match) for future diagnosis.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds tmux-aware session linking, event enrichment, and pane focusing.

Changes:

  • Detects tmux sessions and pane identities.
  • Resolves live tmux targets and activates host terminals.
  • Adds parser and environment extraction tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
FileFinal review
Tests/StackNudgePanelCoreTests/TmuxFocusTests.swiftNo final comments.
Tests/StackNudgePanelCoreTests/EnvVarTerminalIntegrationTests.swiftNo final comments.
shared/AppActivator.swiftNo final comments.
panel/TmuxFocus.swiftNo final comments.
panel/TerminalIntegration.swiftModerate, 3 votes: Include a stable tmux server identity with TMUX_PANE to prevent cross-server or post-restart identity collisions.
panel/SessionStore.swiftModerate, 2 votes: Match decorated macOS tmux process names such as tmux: server.
panel/Panel.swiftNo final comments.
notify.shModerate, 2 votes: Accept decorated tmux server names such as tmux: server during process walking.
Suppressed comments (5)

panel/Panel.swift:2067

  • When TmuxFocus.target returns nil (for a dead agent or a ps failure), this conditional is skipped and execution falls through to the existing activation below. notify_macos supplies com.apple.Terminal for TERM_PROGRAM=tmux via its default case, so a stale tmux event can raise Terminal.app instead of honoring the documented no-op. Treat the tmux branch as handled and return even when target resolution fails.
 let target = TmuxFocus.target(agentPID: agentPID) {

panel/Panel.swift:2335

  • If live resolution fails, this branch is skipped and the following bundleID path runs. For tmux, the hook's existing fallback bundle is Terminal.app, so a dead or unreadable agent PID can incorrectly activate Terminal.app rather than being a no-op. Keep the tmux case exclusive and return after an optional focus attempt, even when target is nil.
 let target = TmuxFocus.target(agentPID: agentPID) {

panel/Panel.swift:3007

  • When TmuxFocus.target cannot resolve the live environment, this condition falls through to the normal bundle activation below. Since tmux events still receive the hook's default Terminal.app bundle, the failure mode is activating the wrong host instead of the documented no-op. Return from the tmux branch regardless of whether a target was found.
 let target = TmuxFocus.target(agentPID: agentPID) {

panel/Panel.swift:3105

  • TmuxFocus.target runs /bin/ps synchronously, but all four callers resolve the target before invoking this helper, so only focusTmux is backgrounded. Banner, keyboard, and Sessions-tab focus actions can therefore block the main UI while ps completes. Move target resolution inside the background closure (for example, make this helper accept the agent PID) before dispatching the tmux commands.
 private func dispatchTmuxFocus(_ target: TmuxFocus.Target, settle: Bool) {
DispatchQueue.global(qos: .userInitiated).async {
if settle { Thread.sleep(forTimeInterval: 0.15) }
AppActivator.focusTmux(pane: target.pane,
socket: target.socket,
hostBundleID: target.hostBundleID)

panel/TmuxFocus.swift:34

  • target runs a synchronous ps/environment read, but every new caller invokes it before dispatchTmuxFocus queues the work. A slow or stuck /bin/ps therefore blocks the panel's main-thread action/notification paths despite the comment claiming resolution is dispatched on a background queue. Move live target resolution into the background dispatch (or expose an async resolver) so focus cannot freeze the UI.
 let raw = ProcessOutput.read(
"/bin/ps", ["eww", "-o", "pid=,command=", "-p", String(agentPID)])
return parse(psOutput: raw, pid: agentPID)

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadnotify.sh
Comment threadpanel/SessionStore.swift
Comment threadpanel/TerminalIntegration.swift Outdated
@StuBehan
StuBehan merged commit 5e08989 into mainAug 21, 2026
6 checks passed
@StuBehan
StuBehan deleted the feat/tmux-tab-linking branch August 21, 2026 13:42
Sign up for freeto 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

@StuBehan