Skip to content
Original file line numberDiff line numberDiff 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")
}
}
61 changes: 61 additions & 0 deletions Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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=iTerm2"
let target = TmuxFocus.parse(psOutput: raw, pid: 42)
XCTAssertEqual(target?.pane, "%1")
XCTAssertNil(target?.socket)
XCTAssertEqual(target?.hostBundleID, "com.googlecode.iterm2")
}

func test_normalizedTitle_stripsAnimatedSpinner() {
// codex renders a braille spinner; different frames must normalize to
// the same stable title so the tmux read and iTerm2 name still match.
XCTAssertEqual(AppActivator.normalizedTitle("⠦ stackone"), "stackone")
XCTAssertEqual(AppActivator.normalizedTitle("⠋ stackone"),
AppActivator.normalizedTitle("⠧ stackone"))
}

func test_normalizedTitle_leavesStablePrefixesAlone() {
// Claude's "✳" is not a braille glyph; agy has no decoration.
XCTAssertEqual(AppActivator.normalizedTitle("✳ Bump stackvox to version 0.6.0"),
"✳ Bump stackvox to version 0.6.0")
XCTAssertEqual(AppActivator.normalizedTitle("StackOne.local"), "StackOne.local")
}

func test_hostBundleID_iTerm2() {
XCTAssertEqual(TmuxFocus.hostBundleID(forLCTerminal: "iTerm2"), "com.googlecode.iterm2")
}

func test_hostBundleID_unmappableHostsAreNil() {
// Terminal.app doesn't propagate LC_TERMINAL through tmux, so it (and
// any other host) resolves to nil — pane select still happens, no raise.
XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: "Apple_Terminal"))
XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: "WezTerm"))
XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: nil))
}
}
29 changes: 29 additions & 0 deletions Tests/StackNudgePanelCoreTests/TmuxIntegrationTests.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
import XCTest

@testable import StackNudgePanelCore

final class TmuxIntegrationTests: XCTestCase {

func test_tabId_composesServerAndPane() {
// TMUX = "<socket>,<serverPID>,<n>" → "<serverPID>:<pane>".
let id = TmuxIntegration.tabId(pane: "%4", tmux: "/private/tmp/tmux-502/default,12390,0")
XCTAssertEqual(id, "12390:%4")
}

func test_tabId_fallsBackToBarePaneWhenTmuxMissing() {
XCTAssertEqual(TmuxIntegration.tabId(pane: "%1", tmux: nil), "%1")
}

func test_tabId_fallsBackWhenTmuxMalformed() {
// No comma → no server field; empty server field → also fall back.
XCTAssertEqual(TmuxIntegration.tabId(pane: "%1", tmux: "nocommas"), "%1")
XCTAssertEqual(TmuxIntegration.tabId(pane: "%2", tmux: "/sock,,0"), "%2")
}

func test_tabId_distinctAcrossServers() {
// Same pane id in two different servers must not collide.
let a = TmuxIntegration.tabId(pane: "%1", tmux: "/sockA,111,0")
let b = TmuxIntegration.tabId(pane: "%1", tmux: "/sockB,222,0")
XCTAssertNotEqual(a, b)
}
}
20 changes: 18 additions & 2 deletions notify.sh
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -496,6 +501,17 @@ post_to_panel() {
local hook_json="$HOOK_JSON"
(( ${#hook_json} > 32768 )) && hook_json=""

# tmux tab identity: "<serverPID>:<pane>" from TMUX="<socket>,<serverPID>,<n>",
# matching TmuxIntegration.tabId so events and sessions share the id. Unique
# across multiple tmux servers, unlike a bare pane id. Outside tmux, fall back
# to the terminal's own session id.
local session_id
if [[ -n "${TMUX:-}" && -n "${TMUX_PANE:-}" ]]; then
session_id="$(printf '%s' "$TMUX" | cut -d, -f2):${TMUX_PANE}"
else
session_id="${TERM_SESSION_ID:-${ITERM_SESSION_ID:-}}"
fi

NUDGE_AGENT="$AGENT" \
NUDGE_EVENT="$EVENT" \
NUDGE_TITLE="$1" \
Expand All@@ -516,7 +532,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="$session_id" \
NUDGE_ITERM_TAB_NAME="${ITERM_TAB_NAME:-}" \
NUDGE_HOOK_JSON="$hook_json" \
python3 - <<'PY' 2>/dev/null
Expand Down
59 changes: 57 additions & 2 deletions panel/Panel.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -2058,6 +2058,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return
}

// tmux-hosted event: no bundleID resolves (TERM_PROGRAM=tmux). Resolve
// the pane + focus off the main thread. Exclusive — a tmux event never
// falls through to the bundle path below, which would raise Terminal.app
// (notify.sh's default bundle for an unknown TERM_PROGRAM).
if config.activateImmediately,
event.termProgram == "tmux" || event.terminalApp == "tmux" {
if let agentPID = event.agentPID {
dispatchTmuxFocus(agentPID: agentPID, settle: false)
}
return
}
if config.activateImmediately, let bundleID = event.bundleID {
DispatchQueue.global(qos: .userInitiated).async {
AppActivator.activate(bundleID: bundleID,
Expand DownExpand Up@@ -2316,6 +2327,18 @@ 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. Exclusive — return even if
// the pane can't be resolved, so we never fall through and raise Terminal.app.
if event.termProgram == "tmux" || event.terminalApp == "tmux" {
if let agentPID = event.agentPID {
NSApp.hide(nil)
dispatchTmuxFocus(agentPID: agentPID, settle: true)
}
return
}
guard let bundleID = event.bundleID else { return }

// Hide the app first so the system restores focus to the previous
Expand DownExpand Up@@ -2979,6 +3002,16 @@ 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). Exclusive — return even if the pane can't be
// resolved, so we never fall through and raise Terminal.app.
if event.termProgram == "tmux" || event.terminalApp == "tmux" {
if let agentPID = event.agentPID {
hidePanel()
dispatchTmuxFocus(agentPID: agentPID, settle: true)
}
return
}
guard let bundleID = event.bundleID else { return }
hidePanel()
DispatchQueue.global(qos: .userInitiated).async {
Expand DownExpand Up@@ -3064,10 +3097,32 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return true
}

// Resolve the tmux target and focus its pane on a background queue — the
// `ps` read in TmuxFocus.target must not run on the UI 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(agentPID: Int, settle: Bool) {
DispatchQueue.global(qos: .userInitiated).async {
if settle { Thread.sleep(forTimeInterval: 0.15) }
guard let target = TmuxFocus.target(agentPID: agentPID) else { return }
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" {
hidePanel()
dispatchTmuxFocus(agentPID: session.pid, 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 numberDiff line numberDiff 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
7 changes: 7 additions & 0 deletions panel/TerminalIntegration.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,13 @@ 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). TmuxIntegration composes the server id with TMUX_PANE into a
// per-pane tabId unique across multiple tmux servers. 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.
TmuxIntegration.shared,
]

static func enrich(_ sessions: [Session]) -> [Session] {
Expand Down
63 changes: 63 additions & 0 deletions panel/TmuxFocus.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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
}

// Only iTerm2 gives a usable host signal through tmux: it sets
// LC_TERMINAL=iTerm2, which survives tmux/ssh. Terminal.app sets
// TERM_PROGRAM=Apple_Terminal — which tmux overwrites with "tmux" — and does
// not propagate LC_TERMINAL, and it has no tmux `-CC` integration anyway, so
// there is no reliable way to identify or raise it from here. nil host means
// focus still selects the pane; only the app-raise/tab-surfacing is skipped.
static func hostBundleID(forLCTerminal lcTerminal: String?) -> String? {
lcTerminal == "iTerm2" ? "com.googlecode.iterm2" : nil
}

// Live resolve: read the agent pid's environment and pull the tmux identity.
// Runs on a background queue (callers dispatch), with a timeout so a hung
// `ps` can't wedge the focus path.
static func target(agentPID: Int) -> Target? {
guard let raw = ProcessOutput.read(
"/bin/ps", ["eww", "-o", "pid=,command=", "-p", String(agentPID)],
timeout: 3) else { return nil }
let resolved = parse(psOutput: raw, pid: agentPID)
debug("target(pid=\(agentPID)) -> " + (resolved.map {
"pane=\($0.pane) socket=\($0.socket ?? "default") host=\($0.hostBundleID ?? "nil")"
} ?? "nil (no TMUX_PANE in that pid's env)"))
return resolved
}

// Gated on STACKNUDGE_PANEL_DEBUG (same switch AppActivator uses). Off by
// default; surfaces what the running app resolved for a focus attempt.
static func debug(_ message: @autoclosure () -> String) {
guard ProcessInfo.processInfo.environment["STACKNUDGE_PANEL_DEBUG"] != nil else { return }
FileHandle.standardError.write(Data("TmuxFocus: \(message())\n".utf8))
}

// 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)
}
}
51 changes: 51 additions & 0 deletions panel/TmuxIntegration.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import Foundation

// Enriches tmux-hosted sessions with a per-pane tabId. A bare TMUX_PANE ("%4")
// is unique only within a single tmux server; a user running multiple servers
// (separate sockets) can have the same %N in each, which would collide the
// per-tab renames/colours keyed on tabId and the event↔session fallback match.
// Compose the server id (the pid in TMUX="<socket>,<serverPID>,<n>") with the
// pane so the id is unique across servers. notify.sh builds the same
// "<serverPID>:<pane>" for event payloads so the two paths agree.
final class TmuxIntegration: TerminalIntegration {

static let shared = TmuxIntegration()

let name = "tmux"

func enrich(_ sessions: [Session]) -> [Session] {
let pids = sessions.filter { $0.terminalApp == "tmux" }.map(\.pid)
guard !pids.isEmpty else { return sessions }

// One `ps eww` for both vars — TMUX_PANE (the pane) and TMUX (carries
// the server id). Reuses the generic env-var parser.
let raw = ProcessOutput.read(
"/bin/ps",
["eww", "-o", "pid=,command=", "-p", pids.map(String.init).joined(separator: ",")])
let panes = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX_PANE")
let tmuxes = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX")
guard !panes.isEmpty else { return sessions }

return sessions.map { session in
guard session.terminalApp == "tmux", let pane = panes[session.pid] else { return session }
var copy = session
copy.tabId = Self.tabId(pane: pane, tmux: tmuxes[session.pid])
return copy
}
}

// "<serverPID>:<pane>" — serverPID is the second comma-field of TMUX
// ("<socket>,<serverPID>,<n>"). Falls back to the bare pane when TMUX is
// absent or malformed. Must stay in sync with notify.sh's session-id build.
static func tabId(pane: String, tmux: String?) -> String {
// serverPID is positional (2nd field), so keep empty fields — otherwise
// a malformed "<socket>,,<n>" would slide the session index into the
// server slot. An empty/absent server field falls back to the bare pane.
guard let server = tmux?
.split(separator: ",", omittingEmptySubsequences: false)
.dropFirst().first.map(String.init),
!server.isEmpty
else { return pane }
return "\(server):\(pane)"
}
}
Loading