From 0bc8498eeb255a912e44f71a15624ae88e09aed9 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 24 Sep 2026 14:38:57 +0100 Subject: [PATCH] fix(panel): find the right iTerm2 tab for tmux panes on any title setup --- .../TmuxFocusTests.swift | 135 ++++++++++ panel/PanelNav.swift | 7 +- panel/Permissions.swift | 50 +++- shared/AppActivator.swift | 235 ++++++++++++++---- 4 files changed, 368 insertions(+), 59 deletions(-) diff --git a/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift index ab695b6..bb186c2 100644 --- a/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift +++ b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift @@ -94,4 +94,139 @@ final class TmuxFocusTests: XCTestCase { XCTAssertNil(actual?.socket) // empty socket → default socket XCTAssertNil(actual?.hostBundleID) // no lcTerminal → no host raise } + + // MARK: - iTerm2 session matching + + private func row(_ guid: String, tty: String = "", pane: String = "", role: String = "", + paneTitle: String = "", autoName: String = "", name: String = "" + ) -> AppActivator.ITermSessionRow { + AppActivator.ITermSessionRow(guid: guid, tty: tty, tmuxPane: pane, tmuxRole: role, + tmuxPaneTitle: paneTitle, autoName: autoName, name: name) + } + + private let controlClient = AppActivator.TmuxClient(controlMode: true, activity: 5, pid: 29208, + tty: "/dev/ttys035") + + private func match(_ pane: String, _ title: String?, + clients: [AppActivator.TmuxClient]?, + _ rows: [AppActivator.ITermSessionRow]) -> String? { + AppActivator.matchITermSession(pane: pane, title: title, clients: clients, in: rows) + } + + func test_parseITermSessions_splitsOnUnitSeparatorAndBlanksMissingValue() { + let raw = ["G1", "missing value", "6", "client", "✳ a|b", "tmux", "✳ a|b (claude)"] + .joined(separator: "\u{1F}") + "\nshort\u{1F}row\n" + + let actual = AppActivator.parseITermSessions(raw) + + XCTAssertEqual(actual, [row("G1", pane: "6", role: "client", paneTitle: "✳ a|b", + autoName: "tmux", name: "✳ a|b (claude)")]) + } + + func test_parseTmuxClients_mostRecentlyActiveFirst() { + let raw = "1 100 29208 /dev/ttys035\n0 300 4411 /dev/ttys004\nbad line\n" + + let actual = AppActivator.parseTmuxClients(raw) + + XCTAssertEqual(actual.map(\.pid), [4411, 29208]) + XCTAssertEqual(actual.map(\.controlMode), [false, true]) + XCTAssertEqual(actual.first?.tty, "/dev/ttys004") + } + + func test_applyClientEnvironment_readsHostAndITermSession() { + // The client's env names the terminal drawing it now, even when the + // agent's env (LC_TERMINAL from when the pane started) says otherwise. + let ps = """ + 29208 tmux -CC __CFBundleIdentifier=com.googlecode.iterm2 ITERM_SESSION_ID=w0t0p0:GW TERM_PROGRAM=iTerm.app + 4411 tmux attach TERM_PROGRAM=Apple_Terminal + """ + let clients = [controlClient, + AppActivator.TmuxClient(controlMode: false, activity: 1, pid: 4411, tty: "/dev/ttys004")] + + let actual = AppActivator.applyClientEnvironment(ps, to: clients) + + XCTAssertEqual(actual.map(\.hostBundleID), ["com.googlecode.iterm2", "com.apple.Terminal"]) + XCTAssertEqual(actual.map(\.iTermSessionGUID), ["GW", nil]) + } + + func test_match_ccPaneNumberWinsOverASharedTitle() { + // "Name (Job)" titles and a profile-named autoName: only the pane number + // tells these two apart. + let rows = [ + row("A", pane: "21", paneTitle: "✳ same", autoName: "tmux", name: "✳ same (claude)"), + row("B", pane: "43", paneTitle: "✳ same", autoName: "tmux", name: "✳ same (claude)"), + ] + XCTAssertEqual(match("%43", "✳ same", clients: [controlClient], rows), "B") + } + + func test_match_piPaneFromSidecarMatchesByNumber() { + let rows = [row("P", pane: "38", paneTitle: "π - stackone", name: "π - stackone (pi)")] + XCTAssertEqual(match("%38", "π - stackone", clients: [controlClient], rows), "P") + } + + func test_match_paneNumbersIgnoredWithoutAControlModeClientOnThisServer() { + // %6 on a server iTerm2 isn't attached to via -CC is not the %6 tab + // iTerm2 shows for some other server. + let rows = [row("OTHER-SERVER", pane: "6", paneTitle: "✳ task", name: "✳ task")] + XCTAssertNil(match("%6", "✳ task", clients: [], rows)) + } + + func test_match_paneNumberTieAcrossServersBreaksOnTitle() { + let rows = [ + row("S1", pane: "6", paneTitle: "✳ one", name: "✳ one (claude)"), + row("S2", pane: "6", paneTitle: "✳ two", name: "✳ two (claude)"), + ] + XCTAssertEqual(match("%6", "✳ two", clients: [controlClient], rows), "S2") + } + + func test_match_tmuxDrawnInAnOrdinaryTabUsesTheClientsITermSession() { + // Not -CC: iTerm2 sees one plain session running the tmux client, named + // whatever tmux set, so the client's own ITERM_SESSION_ID is the key. + var client = AppActivator.TmuxClient(controlMode: false, activity: 1, pid: 4411, tty: "/dev/ttys004") + client.iTermSessionGUID = "HOST" + let rows = [row("OTHER", tty: "/dev/ttys001", name: "zsh"), + row("HOST", tty: "/dev/ttys004", name: "tmux (tmux)")] + XCTAssertEqual(match("%6", "✳ stack-nudge", clients: [client], rows), "HOST") + } + + func test_match_ordinaryTabFallsBackToTheClientTTY() { + let client = AppActivator.TmuxClient(controlMode: false, activity: 1, pid: 4411, tty: "/dev/ttys004") + let rows = [row("OTHER", tty: "/dev/ttys001"), row("HOST", tty: "/dev/ttys004")] + XCTAssertEqual(match("%6", nil, clients: [client], rows), "HOST") + } + + func test_match_titleIsNeverTrustedForTmuxDrawnInAnOrdinaryTab() { + // Without -CC, iTerm2 titles don't mirror pane titles; a same-named + // unrelated tab must not be focused. + let client = AppActivator.TmuxClient(controlMode: false, activity: 1, pid: 4411, tty: "/dev/ttys004") + let rows = [row("UNRELATED", tty: "/dev/ttys009", name: "✳ stack-nudge")] + XCTAssertNil(match("%6", "✳ stack-nudge", clients: [client], rows)) + } + + func test_match_neverSelectsTheGatewaySession() { + let rows = [row("GATEWAY", tty: "/dev/ttys035", role: "gateway", name: "tmux -CC")] + XCTAssertNil(match("%6", "tmux -CC", clients: [controlClient], rows)) + } + + func test_match_preVariableITermFallsBackToName() { + // Before 3.3 there are no tmux variables; name is the only title, + // with or without the job appended. + XCTAssertEqual(match("%6", "✳ stack-nudge", clients: [controlClient], + [row("OLD", name: "✳ stack-nudge")]), "OLD") + XCTAssertEqual(match("%6", "✳ stack-nudge", clients: [controlClient], + [row("OLD", name: "✳ stack-nudge (claude)")]), "OLD") + // tmux unreachable: the -CC fallbacks stay available. + XCTAssertEqual(match("%6", "✳ stack-nudge", clients: nil, + [row("OLD", name: "✳ stack-nudge (claude)")]), "OLD") + } + + func test_match_ambiguousTitleWithoutPaneNumbersIsNil() { + let rows = [row("A", name: "✳ same (claude)"), row("B", name: "✳ same (claude)")] + XCTAssertNil(match("%6", "✳ same", clients: [controlClient], rows)) + } + + func test_match_titlePrefixAloneIsNotAJobSuffix() { + XCTAssertNil(match("%6", "✳ stack-nudge", clients: [controlClient], + [row("A", name: "✳ stack-nudge-extras")])) + } } diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 96c74e6..6b41260 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -1584,20 +1584,23 @@ final class PanelNav: ObservableObject { // MARK: - Permissions - // Probe the three runtime grants and record which aren't set yet. + // Probe the runtime grants and record which aren't set yet. // Notifications is async, so the ordered list is assembled in its callback // (same order the Permissions window renders them). denied and // not-yet-determined both count as missing — the panel can't fully - // function without the grant either way. + // function without the grant either way. iTerm2 is the exception: only a + // denial counts, because an undecided grant is asked for on first focus. func refreshPermissions() { let accessibility = Permissions.accessibility() let automation = Permissions.automation() + let iTerm = Permissions.iTermAutomation() Permissions.notifications { [weak self] notifications in guard let self else { return } var missing: [SettingsPane] = [] if notifications != .granted { missing.append(.notifications) } if accessibility != .granted { missing.append(.accessibility) } if automation != .granted { missing.append(.automation) } + if iTerm == .denied { missing.append(.automationITerm2) } if missing != self.missingPermissions { self.missingPermissions = missing } } } diff --git a/panel/Permissions.swift b/panel/Permissions.swift index e76d95e..70945ce 100644 --- a/panel/Permissions.swift +++ b/panel/Permissions.swift @@ -18,8 +18,8 @@ enum Permissions { AXIsProcessTrusted() ? .granted : .denied } - static func automation() -> PermissionStatus { - let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.systemevents") + static func automation(target bundleID: String = "com.apple.systemevents") -> PermissionStatus { + let target = NSAppleEventDescriptor(bundleIdentifier: bundleID) let status = AEDeterminePermissionToAutomateTarget( target.aeDesc, typeWildCard, typeWildCard, false) switch status { @@ -29,6 +29,16 @@ enum Permissions { } } + // tmux focus selects the iTerm2 tab over AppleScript, a grant separate from + // System Events. nil while iTerm2 isn't running: the grant can only be + // probed against a live target. + static func iTermAutomation() -> PermissionStatus? { + guard !NSRunningApplication + .runningApplications(withBundleIdentifier: AppActivator.iTermBundleID).isEmpty + else { return nil } + return automation(target: AppActivator.iTermBundleID) + } + static func openSettings(_ target: SettingsPane) { NSWorkspace.shared.open(target.url) } @@ -68,6 +78,8 @@ enum Permissions { _ = AXIsProcessTrustedWithOptions(options) case .automation: triggerAutomationPrompt() + case .automationITerm2: + triggerITermAutomationPrompt() case .notifications: promptNotifications() } @@ -102,6 +114,10 @@ enum Permissions { _ = AXIsProcessTrustedWithOptions(options) case .automation: triggerAutomationPrompt() + case .automationITerm2: + // The reset clears every AppleEvents target, System Events included. + triggerAutomationPrompt() + triggerITermAutomationPrompt() case .notifications: promptNotifications() } @@ -121,18 +137,30 @@ enum Permissions { _ = script.executeAndReturnError(&error) } } + + // Only while iTerm2 runs, so asking never launches it. + private static func triggerITermAutomationPrompt() { + guard iTermAutomation() != nil, + let script = NSAppleScript(source: "tell application id \"\(AppActivator.iTermBundleID)\" to get name") + else { return } + DispatchQueue.global(qos: .userInitiated).async { + var error: NSDictionary? + _ = script.executeAndReturnError(&error) + } + } } enum SettingsPane { case accessibility case automation + case automationITerm2 case notifications var url: URL { switch self { case .accessibility: return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! - case .automation: + case .automation, .automationITerm2: return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")! case .notifications: return URL(string: "x-apple.systempreferences:com.apple.preference.notifications")! @@ -142,7 +170,7 @@ enum SettingsPane { var tccService: String? { switch self { case .accessibility: return "Accessibility" - case .automation: return "AppleEvents" + case .automation, .automationITerm2: return "AppleEvents" case .notifications: return nil // notifications aren't a TCC service } } @@ -152,6 +180,7 @@ enum SettingsPane { switch self { case .accessibility: return "Accessibility" case .automation: return "Automation" + case .automationITerm2: return "Automation (iTerm2)" case .notifications: return "Notifications" } } @@ -162,6 +191,7 @@ struct PermissionsView: View { @State private var accessibility: PermissionStatus = .unknown @State private var automation: PermissionStatus = .unknown @State private var notifications: PermissionStatus = .unknown + @State private var iTerm: PermissionStatus? var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -189,6 +219,13 @@ struct PermissionsView: View { status: automation, pane: .automation) + if let iTerm { + row(title: "Automation → iTerm2", + description: "Required to bring the exact iTerm2 tab forward for a tmux-hosted agent.", + status: iTerm, + pane: .automationITerm2) + } + Spacer(minLength: 0) HStack { @@ -198,7 +235,7 @@ struct PermissionsView: View { } } .padding(20) - .frame(width: 480, height: 440) + .frame(width: 480, height: 540) .onAppear { refresh() } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in refresh() @@ -247,6 +284,7 @@ struct PermissionsView: View { private func refresh() { accessibility = Permissions.accessibility() automation = Permissions.automation() + iTerm = Permissions.iTermAutomation() Permissions.notifications { notifications = $0 } } @@ -282,7 +320,7 @@ final class PermissionsWindowController: NSWindowController { convenience init() { let window = EscClosesWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), + contentRect: NSRect(x: 0, y: 0, width: 480, height: 540), styleMask: [.titled, .closable], backing: .buffered, defer: false) window.title = "StackNudge — Permissions" diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index ede8cf1..3d04cc8 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -486,12 +486,11 @@ struct AppActivator { // 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. + // the pane's window; select-pane focuses the pane), then bring the host + // terminal's tab forward. socket nil → tmux default socket. hostBundleID is + // the agent-env guess, used only when no attached client names its host. + // 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 { tmuxDebug("focusTmux: no tmux binary on the probe paths") @@ -502,53 +501,147 @@ struct AppActivator { runDetached(tmux, base + ["select-window", "-t", pane]) runDetached(tmux, base + ["select-pane", "-t", pane]) - // iTerm2 `-CC`: external tmux selection doesn't surface the native tab, - // and the tab has no tty to match on. The one handle iTerm2 exposes is - // that its `-CC` session name mirrors the tmux pane_title — so select - // the iTerm2 session whose name equals the target pane's live title, - // which brings that exact tab + split to the front. Read the title live - // (both sides track it, so they agree at focus time). Ambiguous only - // when two panes share a title; other hosts just get an app raise. - if hostBundleID == "com.googlecode.iterm2" { + let clients = runCapture(tmux, base + ["list-clients", "-t", pane, "-F", tmuxClientFormat]) + .map { withClientEnvironment(parseTmuxClients($0)) } + let host = clients?.lazy.compactMap(\.hostBundleID).first ?? hostBundleID + + // External tmux selection doesn't surface the iTerm2 tab, so select the + // iTerm2 session showing the pane. Skipped when iTerm2 isn't running: + // `tell application` would launch it. + if host == iTermBundleID, + !NSRunningApplication.runningApplications(withBundleIdentifier: iTermBundleID).isEmpty { let title = runCapture( tmux, base + ["display-message", "-p", "-t", pane, "#{pane_title}"])? .trimmingCharacters(in: .whitespacesAndNewlines) - let matched = title.map { !$0.isEmpty && selectITermSessionByName($0) } ?? false - tmuxDebug("focusTmux pane=\(pane) title=«\(title ?? "")» iterm-select=\(matched)") + let rows = listITermSessions() + let guid = rows.flatMap { + matchITermSession(pane: pane, title: title, clients: clients, in: $0) + } + let matched = guid.map { focusIterm2Session(sessionID: $0) } ?? false + tmuxDebug("focusTmux pane=\(pane) title=«\(title ?? "")» clients=\(clients?.count ?? -1) " + + "sessions=\(rows?.count ?? -1) iterm-select=\(matched)") if matched { return } } - if let hostBundleID, !hostBundleID.isEmpty { - tmuxDebug("focusTmux pane=\(pane) → app-raise \(hostBundleID)") + if let host, !host.isEmpty { + tmuxDebug("focusTmux pane=\(pane) → app-raise \(host)") NSRunningApplication - .runningApplications(withBundleIdentifier: hostBundleID) + .runningApplications(withBundleIdentifier: host) .first? .activate(options: [.activateIgnoringOtherApps]) } } - // Select the iTerm2 session whose name matches `name` and bring it forward. - // Under `-CC` the session name mirrors the tmux pane_title, so this focuses - // the exact tab + split. Returns false when no session matches (title - // changed, or not iTerm2) so the caller falls back to a plain app raise. - @discardableResult - private static func selectITermSessionByName(_ title: String) -> Bool { - // Match in Swift, not AppleScript. NSAppleScript mangles non-ASCII - // string literals (Claude's "✳ …" titles decode as MacRoman), and - // `system attribute` mangles them the same way — so ASCII titles - // (codex/agy) matched but Claude titles never did. Reading (id, name) - // OUT is UTF-8-faithful, so enumerate here, match the title in Swift, - // and select by the ASCII GUID via the proven session-id path. Returns - // false when nothing matches (title changed, or not iTerm2) so the - // caller falls back to a plain app raise. + static let iTermBundleID = "com.googlecode.iterm2" + + // A tmux client attached to the pane's session. Host and iTerm2 session come + // from the client's own environment, which the terminal drawing it set at + // attach time. The agent's env is staler: it dates from when the pane's + // shell started, possibly under another terminal or none. + struct TmuxClient: Equatable { + let controlMode: Bool + let activity: Int + let pid: Int + let tty: String + var hostBundleID: String? = nil + var iTermSessionGUID: String? = nil + } + + static let tmuxClientFormat = "#{client_control_mode} #{client_activity} #{client_pid} #{client_tty}" + + // Most recently active first, so the terminal last used to attach wins. + static func parseTmuxClients(_ raw: String) -> [TmuxClient] { + raw.split(separator: "\n").compactMap { line -> TmuxClient? in + let parts = line.split(separator: " ", maxSplits: 3) + guard parts.count == 4, let pid = Int(parts[2]) else { return nil } + return TmuxClient(controlMode: parts[0] == "1", activity: Int(parts[1]) ?? 0, + pid: pid, tty: String(parts[3])) + } + .sorted { $0.activity > $1.activity } + } + + private static func withClientEnvironment(_ clients: [TmuxClient]) -> [TmuxClient] { + guard !clients.isEmpty, + let raw = runCapture("/bin/ps", ["eww", "-o", "pid=,command=", + "-p", clients.map { String($0.pid) }.joined(separator: ",")]) + else { return clients } + return applyClientEnvironment(raw, to: clients) + } + + // `ps eww -o pid=,command=` output → host and iTerm2 session per client. + // __CFBundleIdentifier is set for anything a GUI terminal spawns; + // TERM_PROGRAM covers terminals reached some other way. + static func applyClientEnvironment(_ psOutput: String, to clients: [TmuxClient]) -> [TmuxClient] { + let termPrograms = [ + "iTerm.app": iTermBundleID, + "Apple_Terminal": "com.apple.Terminal", + "ghostty": "com.mitchellh.ghostty", + "WarpTerminal": "dev.warp.Warp-Stable", + ] + var envByPID: [Int: [String: String]] = [:] + for line in psOutput.split(separator: "\n") { + let tokens = line.split(separator: " ") + guard let pid = tokens.first.flatMap({ Int($0) }) else { continue } + var env: [String: String] = [:] + for token in tokens.dropFirst() { + guard let eq = token.firstIndex(of: "=") else { continue } + env[String(token[..