Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,22 @@ final class EnvVarTerminalIntegrationTests: XCTestCase {
let result = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TERM_SESSION_ID")
XCTAssertTrue(result.isEmpty)
}

func test_parseEnvValues_tmuxPane() {
// tmux integration keys off TMUX_PANE; the value starts with "%".
let raw = "99028 /bin/zsh TMUX=/private/tmp/tmux-502/default,12390,0 TMUX_PANE=%4 LC_TERMINAL=iTerm2"
let result = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX_PANE")
XCTAssertEqual(result[99028], "%4")
}

func test_parseEnvValues_tmuxVarDoesNotMatchPaneVar() {
// The needle is "<var>=", so a scan for TMUX must not be satisfied by
// TMUX_PANE=… (the "_" breaks the "TMUX=" match). Order the pane var
// first to prove the socket var is the one found.
let raw = "99028 /bin/zsh TMUX_PANE=%4 TMUX=/private/tmp/tmux-502/default,12390,0"
let socket = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX")
let pane = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX_PANE")
XCTAssertEqual(socket[99028], "/private/tmp/tmux-502/default,12390,0")
XCTAssertEqual(pane[99028], "%4")
}
}
44 changes: 44 additions & 0 deletions Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import XCTest

@testable import StackNudgePanelCore

// Pure-parse tests for the tmux focus resolver. The live path (`target`) needs
// `ps eww` against a real tmux pane, but `parse` is where the extraction rules
// live and is fully pure.
final class TmuxFocusTests: XCTestCase {

func test_parse_extractsPaneSocketAndHost() {
let raw = "99028 /bin/zsh TMUX=/private/tmp/tmux-502/default,12390,0 TMUX_PANE=%4 LC_TERMINAL=iTerm2"
let target = TmuxFocus.parse(psOutput: raw, pid: 99028)
XCTAssertEqual(target?.pane, "%4")
// TMUX is "<socket>,<serverPID>,<sessionN>" — only the socket path.
XCTAssertEqual(target?.socket, "/private/tmp/tmux-502/default")
XCTAssertEqual(target?.hostBundleID, "com.googlecode.iterm2")
}

func test_parse_nilWhenNotInTmux() {
// No TMUX_PANE → the process isn't inside tmux.
let raw = "99028 /bin/zsh TERM_PROGRAM=iTerm.app ITERM_SESSION_ID=w0t1p0:ABC"
XCTAssertNil(TmuxFocus.parse(psOutput: raw, pid: 99028))
}

func test_parse_socketNilWhenTmuxUnset() {
// A pane var with no TMUX socket (unusual, but must not crash): socket
// is nil and focus falls back to the default socket.
let raw = "42 /bin/zsh TMUX_PANE=%1 LC_TERMINAL=Apple_Terminal"
let target = TmuxFocus.parse(psOutput: raw, pid: 42)
XCTAssertEqual(target?.pane, "%1")
XCTAssertNil(target?.socket)
XCTAssertEqual(target?.hostBundleID, "com.apple.Terminal")
}

func test_hostBundleID_knownHosts() {
XCTAssertEqual(TmuxFocus.hostBundleID(forLCTerminal: "iTerm2"), "com.googlecode.iterm2")
XCTAssertEqual(TmuxFocus.hostBundleID(forLCTerminal: "Apple_Terminal"), "com.apple.Terminal")
}

func test_hostBundleID_unknownOrNilIsNil() {
XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: "WezTerm"))
XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: nil))
}
}
9 changes: 7 additions & 2 deletions notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,12 @@ walk_session_chain() {
"Cursor Helper"|"Cursor Helper (Plugin)"|"Cursor Helper (Renderer)"|Cursor|\
"Antigravity Helper"|"Antigravity Helper (Plugin)"|"Antigravity Helper (Renderer)"|Antigravity|\
Zed|zed|\
iTerm2|iTerm|Terminal|Warp|WarpTerminal|ghostty|Ghostty)
iTerm2|iTerm|Terminal|Warp|WarpTerminal|ghostty|Ghostty|\
tmux)
Comment thread
StuBehan marked this conversation as resolved.
# tmux severs the process tree from the host terminal (the agent runs
# under the tmux server, parented to launchd), so the emulator is never
# in the chain. Record the server itself; the panel keys the pane off
# TMUX_PANE (session id below) and focus reads the live env.
TERMINAL_PID="$pid"; TERMINAL_APP="$base"; break ;;
esac
pid=$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ')
Expand Down Expand Up @@ -516,7 +521,7 @@ post_to_panel() {
NUDGE_TERMINAL_PID="${TERMINAL_PID:-}" \
NUDGE_TERMINAL_APP="${TERMINAL_APP:-}" \
NUDGE_TERM_PROGRAM="${TERM_PROGRAM:-}" \
NUDGE_SESSION_ID="${TERM_SESSION_ID:-${ITERM_SESSION_ID:-}}" \
NUDGE_SESSION_ID="${TMUX_PANE:-${TERM_SESSION_ID:-${ITERM_SESSION_ID:-}}}" \
NUDGE_ITERM_TAB_NAME="${ITERM_TAB_NAME:-}" \
NUDGE_HOOK_JSON="$hook_json" \
python3 - <<'PY' 2>/dev/null
Expand Down
55 changes: 53 additions & 2 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2058,6 +2058,16 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return
}

// tmux-hosted event: no bundleID resolves (TERM_PROGRAM=tmux), so focus
// the pane via the tmux server. activate-immediately doesn't hide the
// panel, so no settle wait.
if config.activateImmediately,
event.termProgram == "tmux" || event.terminalApp == "tmux",
let agentPID = event.agentPID,
let target = TmuxFocus.target(agentPID: agentPID) {
dispatchTmuxFocus(target, settle: false)
return
}
if config.activateImmediately, let bundleID = event.bundleID {
DispatchQueue.global(qos: .userInitiated).async {
AppActivator.activate(bundleID: bundleID,
Expand Down Expand Up @@ -2316,6 +2326,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
}

let approve = response.actionIdentifier == "ALLOW"

// tmux-hosted event: focus the pane via the tmux server (no bundleID
// resolves under tmux). The permission decision rides the FIFO, not a
// keystroke, so `approve` doesn't apply here.
if event.termProgram == "tmux" || event.terminalApp == "tmux",
let agentPID = event.agentPID,
let target = TmuxFocus.target(agentPID: agentPID) {
NSApp.hide(nil)
dispatchTmuxFocus(target, settle: true)
return
}
guard let bundleID = event.bundleID else { return }

// Hide the app first so the system restores focus to the previous
Expand Down Expand Up @@ -2979,6 +3000,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
// banner-click path. The 0.15s settle lets our deactivation land before
// the target is raised (without it an approval keystroke can hit our
// own process instead of the target's key window).
// tmux-hosted event: focus the pane via the tmux server (no bundleID
// resolves under tmux).
if event.termProgram == "tmux" || event.terminalApp == "tmux",
let agentPID = event.agentPID,
let target = TmuxFocus.target(agentPID: agentPID) {
hidePanel()
dispatchTmuxFocus(target, settle: true)
return
}
guard let bundleID = event.bundleID else { return }
hidePanel()
DispatchQueue.global(qos: .userInitiated).async {
Expand Down Expand Up @@ -3064,10 +3094,31 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return true
}

// Route a resolved tmux pane to AppActivator on a background queue. `settle`
// waits after a panel/app hide so StackNudge has resigned frontmost before
// the pane is raised (matches the AppActivator.activate call sites).
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)
}
}

private func focusSelectedSession() {
guard let pid = sessions.selectedPID,
let session = sessions.sessions.first(where: { $0.pid == pid }),
let bundleID = bundleID(for: session.terminalApp) else { return }
let session = sessions.sessions.first(where: { $0.pid == pid })
else { return }
// tmux: no terminalApp→bundleID mapping applies (the host emulator isn't
// in the process tree), so focus the pane via the tmux server instead.
if session.terminalApp == "tmux",
let target = TmuxFocus.target(agentPID: session.pid) {
hidePanel()
dispatchTmuxFocus(target, settle: true)
return
}
guard let bundleID = bundleID(for: session.terminalApp) else { return }
hidePanel()
// session.tabId is the per-tab identity our terminal integrations
// captured, but the underlying value differs per terminal — so it
Expand Down
9 changes: 9 additions & 0 deletions panel/SessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,15 @@ final class SessionStore: ObservableObject {
private static func canonicalTerminalApp(_ processName: String) -> String? {
if terminalApps.contains(processName) { return processName }
if processName.hasPrefix("iTermServer") { return "iTerm2" }
// tmux severs the process tree from the host terminal: the agent runs
// under the tmux *server* (parented to launchd), so the host emulator
// (iTerm2/Terminal/…) is never in the parent chain to walk up to. Left
// unmapped, every session inside tmux gets no terminalApp and is
// dropped from enrichment/focus. Recognise the server itself; the
// per-pane tabId comes from TMUX_PANE via the tmux EnvVarTerminal
// integration, and host-terminal focus (LC_TERMINAL + `tmux
// select-pane`) is handled in AppActivator.
if processName == "tmux" { return "tmux" }
Comment thread
StuBehan marked this conversation as resolved.
return nil
}

Expand Down
11 changes: 11 additions & 0 deletions panel/TerminalIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ enum TerminalRegistry {
terminalApps: ["Ghostty", "ghostty"],
envVar: "TERM_SESSION_ID"
),
// tmux: SessionStore.walkParentChain dead-ends at the tmux server and
// emits terminalApp "tmux" (the host emulator isn't in the parent
// chain). TMUX_PANE (e.g. "%4") is the stable per-pane id, unique
// within a server. No tab name — tmux exposes none via env. Focus into
// the pane (and, under iTerm2 `-CC`, the mapped tab) is AppActivator's
// job, not this conformer's.
EnvVarTerminalIntegration(
name: "tmux",
terminalApps: ["tmux"],
envVar: "TMUX_PANE"
Comment thread
StuBehan marked this conversation as resolved.
Outdated
),
]

static func enrich(_ sessions: [Session]) -> [Session] {
Expand Down
51 changes: 51 additions & 0 deletions panel/TmuxFocus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

// Resolves a tmux-hosted agent to the values AppActivator needs to focus its
// pane. tmux severs the process tree from the host terminal — the agent runs
// under the tmux server (parented to launchd), so none of the usual terminal
// enrichment reaches iTerm2/Terminal. Instead we read the agent process's live
// environment (TMUX socket, TMUX_PANE, LC_TERMINAL) at focus time. Reading it
// live rather than storing it keeps custom sockets and the host terminal
// current, and a dead pid simply yields nil (focus becomes a no-op).
enum TmuxFocus {

struct Target: Equatable {
let pane: String // TMUX_PANE, e.g. "%4"
let socket: String? // tmux server socket path; nil → default socket
let hostBundleID: String? // app to raise; nil → rely on -CC tab surfacing
}

// iTerm2 and Terminal.app propagate LC_TERMINAL through tmux/ssh. Only map
// the hosts we can actually raise; anything else leaves hostBundleID nil,
// so focus still selects the pane and (under iTerm2 `-CC`) the mapped tab
// still surfaces.
static func hostBundleID(forLCTerminal lcTerminal: String?) -> String? {
switch lcTerminal {
case "iTerm2": return "com.googlecode.iterm2"
case "Apple_Terminal": return "com.apple.Terminal"
default: return nil
}
}

// Live resolve: read the agent pid's environment and pull the tmux identity.
static func target(agentPID: Int) -> Target? {
let raw = ProcessOutput.read(
"/bin/ps", ["eww", "-o", "pid=,command=", "-p", String(agentPID)])
return parse(psOutput: raw, pid: agentPID)
}

// Pure: given `ps eww` output and the pid, extract the tmux target. Returns
// nil when the process isn't inside tmux (no TMUX_PANE). Reuses the generic
// env-var parser so the extraction rules stay in one place.
static func parse(psOutput raw: String, pid: Int) -> Target? {
let panes = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX_PANE")
guard let pane = panes[pid], !pane.isEmpty else { return nil }
// TMUX is "<socket>,<serverPID>,<sessionN>" — the socket is the part
// before the first comma; tmux -S wants just that path.
let socket = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX")[pid]
.flatMap { $0.split(separator: ",").first.map(String.init) }
let host = hostBundleID(forLCTerminal:
EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "LC_TERMINAL")[pid])
return Target(pane: pane, socket: socket, hostBundleID: host)
}
}
47 changes: 47 additions & 0 deletions shared/AppActivator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -482,4 +482,51 @@ struct AppActivator {
}
return false
}

// MARK: - tmux

// Focus a tmux pane by talking to the tmux server (select-window resolves
// the pane's window; select-pane focuses the pane), then raise the host
// terminal. Under iTerm2 `-CC` control mode the window select surfaces the
// mapped native tab; under plain tmux it switches the active pane inside
// the host's single window. socket nil → tmux default socket. hostBundleID
// nil → skip the raise (rely on -CC surfacing the tab). Callers resolve the
// pane/socket/host via TmuxFocus and dispatch this on a background queue.
static func focusTmux(pane: String, socket: String?, hostBundleID: String?) {
guard let tmux = tmuxPath() else { return }
var base: [String] = []
if let socket, !socket.isEmpty { base += ["-S", socket] }
runDetached(tmux, base + ["select-window", "-t", pane])
runDetached(tmux, base + ["select-pane", "-t", pane])
if let hostBundleID, !hostBundleID.isEmpty {
NSRunningApplication
.runningApplications(withBundleIdentifier: hostBundleID)
.first?
.activate(options: [.activateIgnoringOtherApps])
}
}

// Resolve the tmux binary from common install locations. A launchd-spawned
// app has a minimal PATH, so probe paths directly (same rationale as the
// gh/claude resolvers). Self-contained here to keep shared/ independent of
// panel/'s ProcessOutput.
private static func tmuxPath() -> String? {
let home = NSHomeDirectory()
return [
"/opt/homebrew/bin/tmux",
"/usr/local/bin/tmux",
"\(home)/.local/bin/tmux",
"/usr/bin/tmux",
].first { FileManager.default.isExecutableFile(atPath: $0) }
}

private static func runDetached(_ path: String, _ args: [String]) {
let task = Process()
task.executableURL = URL(fileURLWithPath: path)
task.arguments = args
task.standardOutput = Pipe()
task.standardError = Pipe()
try? task.run()
task.waitUntilExit()
}
}