diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 0f1826cc..7fccc049 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -267,12 +267,19 @@ _cmux_report_shell_activity_state() { _cmux_report_tmux_state_payload() { [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 local state="outside" [[ -n "$TMUX" ]] && state="inside" - printf '%s\n' "report_tmux_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + local payload="report_tmux_state $state --tab=$CMUX_TAB_ID" + if [[ -n "$TMUX" ]]; then + [[ -n "$_CMUX_TTY_NAME" ]] && payload+=" --tty=$_CMUX_TTY_NAME" + else + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + payload+=" --panel=$CMUX_PANEL_ID" + fi + + printf '%s\n' "$payload" } _cmux_tmux_state_report_signature() { @@ -536,7 +543,6 @@ _cmux_preexec_command() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 if [[ -z "$_CMUX_TTY_NAME" ]]; then local t @@ -545,9 +551,12 @@ _cmux_preexec_command() { [[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" fi - _cmux_report_shell_activity_state running + if [[ -n "$CMUX_PANEL_ID" ]]; then + _cmux_report_shell_activity_state running + fi _cmux_report_tmux_state _cmux_report_tty_once + [[ -n "$CMUX_PANEL_ID" ]] || return 0 _cmux_ports_kick _cmux_stop_pr_poll_loop } @@ -561,9 +570,21 @@ _cmux_prompt_command() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 - _cmux_report_shell_activity_state prompt + + if [[ -z "$_CMUX_TTY_NAME" ]]; then + local t + t="$(tty 2>/dev/null || true)" + t="${t##*/}" + [[ "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" + fi + + if [[ -n "$CMUX_PANEL_ID" ]]; then + _cmux_report_shell_activity_state prompt + fi _cmux_report_tmux_state + _cmux_report_tty_once + + [[ -n "$CMUX_PANEL_ID" ]] || return 0 local now=$SECONDS local pwd="$PWD" @@ -580,16 +601,6 @@ _cmux_prompt_command() { fi fi - # Resolve TTY name once. - if [[ -z "$_CMUX_TTY_NAME" ]]; then - local t - t="$(tty 2>/dev/null || true)" - t="${t##*/}" - [[ "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" - fi - - _cmux_report_tty_once - # CWD: keep the app in sync with the actual shell directory. if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then _CMUX_PWD_LAST_PWD="$pwd" diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 87a0b07c..63533d48 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -372,12 +372,19 @@ _cmux_report_shell_activity_state() { _cmux_report_tmux_state_payload() { [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 local state="outside" [[ -n "$TMUX" ]] && state="inside" - print -r -- "report_tmux_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + local payload="report_tmux_state $state --tab=$CMUX_TAB_ID" + if [[ -n "$TMUX" ]]; then + [[ -n "$_CMUX_TTY_NAME" ]] && payload+=" --tty=$_CMUX_TTY_NAME" + else + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + payload+=" --panel=$CMUX_PANEL_ID" + fi + + print -r -- "$payload" } _cmux_tmux_state_report_signature() { @@ -725,9 +732,6 @@ _cmux_precmd() { # Skip if socket doesn't exist yet [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 - _cmux_report_shell_activity_state prompt - _cmux_report_tmux_state # Handle cases where Ghostty integration initializes after this file. (( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw @@ -739,8 +743,14 @@ _cmux_precmd() { [[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" fi + if [[ -n "$CMUX_PANEL_ID" ]]; then + _cmux_report_shell_activity_state prompt + fi + _cmux_report_tmux_state _cmux_report_tty_once + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + local now=$EPOCHSECONDS local pwd="$PWD" local cmd_start="$_CMUX_CMD_START" diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 623b2c44..a5b349dd 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -520,6 +520,42 @@ class TerminalController { return trimmed } + nonisolated static func normalizedReportedTTYName(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty, + trimmed != "not a tty" else { + return nil + } + let components = trimmed.split(separator: "/") + if let last = components.last, !last.isEmpty { + return String(last) + } + return trimmed + } + + static func resolvePanelIdByTTY(_ ttyName: String?, in tab: Tab) -> UUID? { + guard let ttyName = normalizedReportedTTYName(ttyName) else { + return nil + } + + if let focusedPanelId = tab.focusedPanelId, + normalizedReportedTTYName(tab.surfaceTTYNames[focusedPanelId]) == ttyName, + tab.panels[focusedPanelId] != nil { + return focusedPanelId + } + + let matches = tab.surfaceTTYNames.compactMap { (panelId, candidateTTY) -> UUID? in + guard tab.panels[panelId] != nil, + normalizedReportedTTYName(candidateTTY) == ttyName else { + return nil + } + return panelId + } + + guard matches.count == 1 else { return nil } + return matches[0] + } + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) @@ -11153,7 +11189,7 @@ class TerminalController { report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel report_shell_state [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command - report_tmux_state [--tab=X] [--panel=Y] - Report whether the shell is currently inside tmux + report_tmux_state [--tab=X] [--panel=Y] [--tty=Z] - Report whether the shell is currently inside tmux report_pwd [--tab=X] [--panel=Y] - Report current working directory clear_ports [--tab=X] [--panel=Y] - Clear listening ports sidebar_state [--tab=X] - Dump sidebar metadata @@ -15207,12 +15243,27 @@ class TerminalController { private func reportTmuxState(_ args: String) -> String { let parsed = parseOptions(args) guard let rawState = parsed.positional.first, !rawState.isEmpty else { - return "ERROR: Missing tmux state — usage: report_tmux_state [--tab=X] [--panel=Y]" + return "ERROR: Missing tmux state — usage: report_tmux_state [--tab=X] [--panel=Y] [--tty=Z]" } guard let isInsideTmux = Self.parseReportedTmuxState(rawState) else { return "ERROR: Invalid tmux state '\(rawState)' — expected inside or outside" } + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let fallbackPanelId: UUID? + if let panelArg { + if panelArg.isEmpty { + return "ERROR: Missing panel id — usage: report_tmux_state [--tab=X] [--panel=Y] [--tty=Z]" + } + guard let parsedId = UUID(uuidString: panelArg) else { + return "ERROR: Invalid panel id '\(panelArg)'" + } + fallbackPanelId = parsedId + } else { + fallbackPanelId = nil + } + let reportedTTYName = Self.normalizedReportedTTYName(parsed.options["tty"]) + if let scope = Self.explicitSocketScope(options: parsed.options) { guard Self.socketFastPathState.shouldPublishTmuxState( workspaceId: scope.workspaceId, @@ -15232,46 +15283,35 @@ class TerminalController { return "OK" } - guard let tabManager else { return "ERROR: TabManager not available" } + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } - var result = "OK" - DispatchQueue.main.sync { - guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + DispatchQueue.main.async { [weak self] in + guard let self, + let tab = self.tabForSidebarMutation(id: targetTabId) else { return } let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: report_tmux_state [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + let surfaceId = fallbackPanelId + ?? Self.resolvePanelIdByTTY(reportedTTYName, in: tab) + ?? tab.focusedPanelId + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { return } + guard Self.socketFastPathState.shouldPublishTmuxState( + workspaceId: tab.id, + panelId: surfaceId, + isInsideTmux: isInsideTmux + ) else { return } - tabManager.updateSurfaceTmuxState(tabId: tab.id, surfaceId: surfaceId, isInsideTmux: isInsideTmux) + tab.updatePanelTmuxState(panelId: surfaceId, isInsideTmux: isInsideTmux) } - return result + return "OK" } private func clearPorts(_ args: String) -> String { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index a058b958..ec79ad9a 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -3001,7 +3001,10 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { let output = try runInteractiveZsh( cmuxLoadGhosttyIntegration: false, cmuxLoadShellIntegration: true, - command: "print -r -- \"$(_cmux_report_tmux_state_payload)\"", + command: """ + _CMUX_TTY_NAME=ttys999 + print -r -- "$(_cmux_report_tmux_state_payload)" + """, extraEnvironment: [ "TMUX": "/tmp/tmux-current,123,0", "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", @@ -3011,7 +3014,7 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertEqual( output, - "report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999" + "report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999" ) } @@ -3035,6 +3038,7 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { server_a=$! sleep 0.1 functions[_cmux_send_bg]='print -r -- "$1"' + _CMUX_TTY_NAME=ttys999 _CMUX_TMUX_STATE_SIGNATURE_LAST="" _cmux_report_tmux_state kill $server_a >/dev/null 2>&1 @@ -3061,8 +3065,51 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertEqual( output, """ - report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999 - report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999 + report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999 + report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999 + """ + ) + } + + func testShellIntegrationPrecmdReportsTmuxStateWithoutPanelScope() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-precmd-tmux-state-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let socketPath = root.appendingPathComponent("cmux.sock").path + + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + python3 -c 'import os, socket, sys, time; path = sys.argv[1]; \ + os.path.exists(path) and os.unlink(path); \ + s = socket.socket(socket.AF_UNIX); s.bind(path); s.listen(1); time.sleep(3)' "$CMUX_SOCKET_PATH" & + server_pid=$! + sleep 0.1 + functions[_cmux_send_bg]='print -r -- "$1"' + unset CMUX_PANEL_ID + _CMUX_TTY_NAME=ttys999 + _CMUX_TTY_REPORTED=0 + _CMUX_TMUX_STATE_SIGNATURE_LAST="" + _cmux_precmd + kill $server_pid >/dev/null 2>&1 + wait $server_pid >/dev/null 2>&1 + """, + extraEnvironment: [ + "TMUX": "/tmp/tmux-current,123,0", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", + ] + ) + + XCTAssertEqual( + output, + """ + report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999 + report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111 """ ) } diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 8f4232ce..c995bc6f 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -254,6 +254,41 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertTrue(manager.tabs.contains(where: { $0.id == pinnedWorkspace.id })) } + func testReportTmuxStateResolvesPanelByTTY() throws { + let socketPath = makeSocketPath("tmux-tty") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true) + + guard let focusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a focused panel") + return + } + guard let targetPanel = workspace.newTerminalSplit(from: focusedPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + workspace.focusPanel(focusedPanelId) + workspace.surfaceTTYNames[targetPanel.id] = "/dev/ttys777" + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let responses = try sendCommands( + ["report_tmux_state inside --tab=\(workspace.id.uuidString) --tty=ttys777"], + to: socketPath + ) + XCTAssertEqual(responses, ["OK"]) + + try waitForCondition("tmux state routed by tty") { + workspace.panelIsInsideTmux(panelId: targetPanel.id) + } + XCTAssertFalse(workspace.panelIsInsideTmux(panelId: focusedPanelId)) + } + private func waitForSocket(at path: String, timeout: TimeInterval = 5.0) throws { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in @@ -268,6 +303,22 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) } + private func waitForCondition( + _ description: String, + timeout: TimeInterval = 5.0, + condition: @escaping () -> Bool + ) throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return + } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + XCTFail("Timed out waiting for \(description)") + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) + } + private func socketMode(at path: String) throws -> UInt16 { var fileInfo = stat() guard lstat(path, &fileInfo) == 0 else {