Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3
feat(sessions): link tabs and focus panes under tmux#160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a716960
feat(sessions): link tabs and focus panes under tmux
StuBehan 7c80e6d
feat(sessions): surface iTerm2 -CC tab by matching pane title
StuBehan 0823d37
fix(sessions): match iTerm2 -CC tab title unicode-safely
StuBehan 1169fa5
chore(sessions): debug-log tmux focus resolution behind panel flag
StuBehan 693146f
fix(sessions): force UTF-8 locale for tmux title read under launchd
StuBehan d0f40ae
fix(sessions): harden tmux focus (host/fall-through/tab-id/threading)
StuBehan 1c440f4
fix(sessions): keep empty fields when parsing TMUX server id
StuBehan 8fe8d74
fix(sessions): strip animated spinner from tmux title match
StuBehan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
18 changes: 18 additions & 0 deletions
18 Tests/StackNudgePanelCoreTests/EnvVarTerminalIntegrationTests.swift
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
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
| Original file line number | Diff line number | Diff 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)) | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff 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) | ||
| } | ||
| } |
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" } | ||
StuBehan marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return nil | ||
| } | ||
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
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
| Original file line number | Diff line number | Diff 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) | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff 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)" | ||
| } | ||
| } |
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.