Uh oh!
There was an error while loading. Please reload this page.
feat(sessions): link tabs and focus panes under tmux - #160
Merged
Conversation
There was a problem hiding this comment.
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
| File | Final review |
|---|---|
Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift | No final comments. |
Tests/StackNudgePanelCoreTests/EnvVarTerminalIntegrationTests.swift | No final comments. |
shared/AppActivator.swift | No final comments. |
panel/TmuxFocus.swift | No final comments. |
panel/TerminalIntegration.swift | Moderate, 3 votes: Include a stable tmux server identity with TMUX_PANE to prevent cross-server or post-restart identity collisions. |
panel/SessionStore.swift | Moderate, 2 votes: Match decorated macOS tmux process names such as tmux: server. |
panel/Panel.swift | No final comments. |
notify.sh | Moderate, 2 votes: Accept decorated tmux server names such as tmux: server during process walking. |
Suppressed comments (5)
panel/Panel.swift:2067
- When
TmuxFocus.targetreturns nil (for a dead agent or apsfailure), this conditional is skipped and execution falls through to the existing activation below.notify_macossuppliescom.apple.TerminalforTERM_PROGRAM=tmuxvia 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
bundleIDpath 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 whentargetis nil.
let target = TmuxFocus.target(agentPID: agentPID) {
panel/Panel.swift:3007
- When
TmuxFocus.targetcannot 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.targetruns/bin/pssynchronously, but all four callers resolve the target before invoking this helper, so onlyfocusTmuxis backgrounded. Banner, keyboard, and Sessions-tab focus actions can therefore block the main UI whilepscompletes. 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
targetruns a synchronousps/environment read, but every new caller invokes it beforedispatchTmuxFocusqueues the work. A slow or stuck/bin/pstherefore 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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 —
terminalAppunset, session dropped, event carries no terminal. AndTERM_PROGRAMistmux, notiTerm.app, so paths gated on it fall through, andITERM_SESSION_IDin a pane is the tmux server's stale original pane.Change
SessionStore+TerminalIntegration): recognise the tmux server in the parent-chain walk (terminalApp = "tmux") and register aTMUX_PANEenv-var conformer, giving each session a stable per-panetabId(renames/colours/scoping key off it).notify.sh): recognisetmuxinwalk_session_chainand preferTMUX_PANEover the staleITERM_SESSION_ID, so events carry the same pane identity as sessions.TmuxFocus+AppActivator.focusTmux): resolve the target from the agent pid's live env (TMUXsocket,TMUX_PANE,LC_TERMINAL), thentmux select-window/select-paneand raise the host. Routed from all four focus sites, which previously bailed on the missing bundleID.iTerm2
-CCExternal
tmux select-windowdoesn't surface iTerm2's native tab, and-CCtabs expose no tty to match on. The one handle iTerm2 gives is that its-CCsessionnamemirrors the tmuxpane_title, sofocusTmuxselects the iTerm2 session whose name equals the target pane's live title — bringing the exact tab + split forward. Two gotchas fixed along the way:system attribute) decode non-ASCII as MacRoman, mangling Claude's✳ …titles.tmux display-messagerendered✳as_, breaking the match. codex/agy (ASCII titles) masked this until it was isolated.Not covered
-CCplain tmux switches the active pane only (no per-tab mapping to surface).LC_TERMINALhosts get the pane select without an explicit raise.Testing
swiftc -typecheck+bash -n/shellcheck clean; unit tests forTmuxFocus.parseand the env-var parser; CI green.-CC: Sessions-tab and event focus land on the correct tab/split for Claude, codex, and agy. ASTACKNUDGE_PANEL_DEBUGswitch logs the focus resolution (pane / title / match) for future diagnosis.