From fe8e7840e7efe90e1011cff266fd4112857fdbc2 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Wed, 1 Jul 2026 15:53:03 +0100 Subject: [PATCH] fix(sessions): recognise iTermServer daemon so focus works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iTerm2 3.5+ runs each session under a detached iTermServer- daemon parented to launchd, not under the iTerm2 app — so terminalApp never matched, which dropped tab enrichment and made Enter-to-focus a silent no-op. Normalise the daemon to "iTerm2", route session.tabId to the correct activation param (ipcHook for VSCode/Cursor, sessionID for iTerm2), and match iTerm2 sessions on `unique id` (with `id` fallback). Co-Authored-By: Claude Opus 4.8 (1M context) --- panel/Panel.swift | 15 ++++++++++++++- panel/SessionStore.swift | 22 ++++++++++++++++++++-- shared/AppActivator.swift | 6 +++++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index e3c8cfd..5f0ce60 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -2743,12 +2743,25 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let session = sessions.sessions.first(where: { $0.pid == pid }), 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 + // has to reach AppActivator via the parameter that terminal's + // activation path reads. VSCode/Cursor/Antigravity store + // VSCODE_IPC_HOOK_CLI (→ ipcHook, disambiguates windows for + // --reuse-window); iTerm2 stores its session GUID (→ sessionID, + // selects the exact pane). Ghostty/Warp/Terminal fall back to + // projectPath / AX tab-title and need neither. Without this the + // captured tabId was dropped and focus landed on whatever window + // the app last had frontmost. + let ipcHook = VSCodeIntegration.isVSCodeHosted(session.terminalApp) ? session.tabId : nil + let sessionID = session.terminalApp?.contains("iTerm") == true ? session.tabId : nil DispatchQueue.global(qos: .userInitiated).async { AppActivator.activate( bundleID: bundleID, windowTitle: session.projectName, - ipcHook: nil, + ipcHook: ipcHook, projectPath: session.projectPath, + sessionID: sessionID, sendApproval: false, agent: session.agent ) diff --git a/panel/SessionStore.swift b/panel/SessionStore.swift index 8b626d7..6286309 100644 --- a/panel/SessionStore.swift +++ b/panel/SessionStore.swift @@ -446,6 +446,24 @@ final class SessionStore: ObservableObject { "iTerm2", "iTerm", "Terminal", "Warp", "WarpTerminal", "ghostty", "Ghostty", ] + // Resolve a raw `ps` process name to the canonical terminal-app name the + // rest of the app keys off (bundleID lookup, iTerm enrichment, focus). + // Most terminals present with a stable name already in `terminalApps`. + // iTerm2 3.5+ is the exception: each session runs under a detached + // `iTermServer-` daemon (session restoration) parented to + // launchd, not under the iTerm2 app — so the process name is + // version-suffixed (e.g. "iTermServer-3.6.11") and never equals "iTerm2", + // and the app itself isn't in the parent chain to walk up to. Left + // unmapped, those sessions get no terminalApp — which drops their tab + // enrichment and makes Enter-to-focus a silent no-op. Map the daemon back + // to "iTerm2" so every downstream check (which matches "iTerm2" exactly or + // via contains("iTerm")) recognises it. + private static func canonicalTerminalApp(_ processName: String) -> String? { + if terminalApps.contains(processName) { return processName } + if processName.hasPrefix("iTermServer") { return "iTerm2" } + return nil + } + private static func readProcessTable() -> [Int: ProcessInfo] { let output = runProcess("/bin/ps", ["-axww", "-o", "pid=,ppid=,comm="]) var result: [Int: ProcessInfo] = [:] @@ -473,9 +491,9 @@ final class SessionStore: ObservableObject { let info = processTable[next] else { break } let base = (info.command as NSString).lastPathComponent - if terminalApps.contains(base) { + if let canonical = canonicalTerminalApp(base) { chain.terminalPID = next - chain.terminalApp = base + chain.terminalApp = canonical return chain } current = info.parentPID diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index bf8c2ef..b197e9c 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -356,7 +356,11 @@ struct AppActivator { repeat with t in tabs of w repeat with s in sessions of t try - if (id of s as text) is target then + -- Match on `unique id` (the persistent session GUID that + -- both ITERM_SESSION_ID and our tab enrichment carry); + -- keep `id` as a fallback for iTerm2 versions where the + -- two properties diverge. + if ((unique id of s) as text) is target or ((id of s) as text) is target then tell w to select tell t to select tell s to select