From 540015537d58d493d5aeb1d0824f17823ac0f189 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 30 Mar 2026 02:18:15 -0700 Subject: [PATCH 1/5] Map Shift+Enter to raw newline in Ghostty --- Sources/GhosttyTerminalView.swift | 10 ++++++++++ Sources/cmuxApp.swift | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 625bc5a4..602c975d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1382,11 +1382,21 @@ class GhosttyApp { ) } + private func loadShiftEnterOverride(_ config: ghostty_config_t) { + loadInlineGhosttyConfig( + TerminalShiftEnterSettings.overrideConfigLine, + into: config, + prefix: "cmux-shift-enter", + logLabel: "shift-enter override" + ) + } + private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { ghostty_config_load_default_files(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) loadCmuxAppSupportGhosttyConfigIfNeeded(config) + loadShiftEnterOverride(config) loadCopyOnSelectOverride(config) loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index c16f88b5..da609bc2 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3801,6 +3801,10 @@ enum TerminalCopyOnSelectSettings { } } +enum TerminalShiftEnterSettings { + static let overrideConfigLine = #"keybind = shift+enter=text:\x0a"# +} + enum CommandPaletteSwitcherSearchSettings { static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces" static let defaultSearchAllSurfaces = false From 9080248393a8c1effcb2ec86d75a6ba26bdc74b4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 30 Mar 2026 02:55:19 -0700 Subject: [PATCH 2/5] Gate Shift+Enter newline remap to tmux --- .../cmux-bash-integration.bash | 29 +++ .../cmux-zsh-integration.zsh | 27 +++ Sources/GhosttyTerminalView.swift | 211 ++++++++++++++++-- Sources/TabManager.swift | 9 + Sources/TerminalController.swift | 107 +++++++++ Sources/Workspace.swift | 22 ++ Sources/cmuxApp.swift | 4 - cmuxTests/GhosttyConfigTests.swift | 110 +++++++++ 8 files changed, 495 insertions(+), 24 deletions(-) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 1d54c670..99de51f3 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -61,6 +61,7 @@ _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}" +_CMUX_TMUX_STATE_LAST="${_CMUX_TMUX_STATE_LAST:-}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" _CMUX_TMUX_PUSH_SIGNATURE="${_CMUX_TMUX_PUSH_SIGNATURE:-}" @@ -264,6 +265,32 @@ _cmux_report_shell_activity_state() { } >/dev/null 2>&1 & disown } +_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" +} + +_cmux_report_tmux_state() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + + local payload="" + payload="$(_cmux_report_tmux_state_payload)" + [[ -n "$payload" ]] || return 0 + + local state="${payload#report_tmux_state }" + state="${state%% *}" + [[ "$_CMUX_TMUX_STATE_LAST" == "$state" ]] && return 0 + _CMUX_TMUX_STATE_LAST="$state" + { + _cmux_send "$payload" + } >/dev/null 2>&1 & disown +} + _cmux_ports_kick() { # Lightweight: just tell the app to run a batched scan for this panel. # The app coalesces kicks across all panels and runs a single ps+lsof. @@ -511,6 +538,7 @@ _cmux_preexec_command() { fi _cmux_report_shell_activity_state running + _cmux_report_tmux_state _cmux_report_tty_once _cmux_ports_kick _cmux_stop_pr_poll_loop @@ -527,6 +555,7 @@ _cmux_prompt_command() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 _cmux_report_shell_activity_state prompt + _cmux_report_tmux_state local now=$SECONDS local pwd="$PWD" diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 273d062c..d47bcc3d 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -74,6 +74,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20 typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_SHELL_ACTIVITY_LAST="" +typeset -g _CMUX_TMUX_STATE_LAST="" typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 @@ -369,6 +370,30 @@ _cmux_report_shell_activity_state() { _cmux_send_bg "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_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" +} + +_cmux_report_tmux_state() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + + local payload="" + payload="$(_cmux_report_tmux_state_payload)" + [[ -n "$payload" ]] || return 0 + + local state="${payload#report_tmux_state }" + state="${state%% *}" + [[ "$_CMUX_TMUX_STATE_LAST" == "$state" ]] && return 0 + _CMUX_TMUX_STATE_LAST="$state" + _cmux_send_bg "$payload" +} + _cmux_ports_kick() { # Lightweight: just tell the app to run a batched scan for this panel. # The app coalesces kicks across all panels and runs a single ps+lsof. @@ -668,6 +693,7 @@ _cmux_preexec() { _CMUX_CMD_START=$EPOCHSECONDS _cmux_report_shell_activity_state running + _cmux_report_tmux_state # Heuristic: commands that may change git branch/dirty state without changing $PWD. local cmd="${1## }" @@ -693,6 +719,7 @@ _cmux_precmd() { [[ -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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 602c975d..8c09d27d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -949,6 +949,7 @@ class GhosttyApp { private var backgroundEventCounter: UInt64 = 0 private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped private var defaultBackgroundScopeSource: String = "initialize" + private(set) var userConfigDefinesShiftEnterBinding = false private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference? private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher = // Theme chrome should track terminal theme changes in the same frame. @@ -1382,21 +1383,12 @@ class GhosttyApp { ) } - private func loadShiftEnterOverride(_ config: ghostty_config_t) { - loadInlineGhosttyConfig( - TerminalShiftEnterSettings.overrideConfigLine, - into: config, - prefix: "cmux-shift-enter", - logLabel: "shift-enter override" - ) - } - private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { ghostty_config_load_default_files(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) loadCmuxAppSupportGhosttyConfigIfNeeded(config) - loadShiftEnterOverride(config) + userConfigDefinesShiftEnterBinding = Self.userConfigDefinesShiftEnterBinding() loadCopyOnSelectOverride(config) loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) @@ -1593,6 +1585,12 @@ class GhosttyApp { ) != nil } + static func userConfigDefinesShiftEnterBinding( + configPaths: [String] = loadedCJKScanPaths() + ) -> Bool { + userShiftEnterConfigSummary(configPaths: configPaths).containsExplicitShiftEnterDirective + } + private static func configuredCTFont( named name: String, size: CGFloat = 12 @@ -1670,6 +1668,50 @@ class GhosttyApp { return summary } + private struct UserShiftEnterConfigSummary { + var containsExplicitShiftEnterDirective = false + + mutating func recordKeybind(_ value: String) { + if GhosttyApp.keybindDirectiveTargetsShiftEnter(value) { + containsExplicitShiftEnterDirective = true + } + } + } + + private static func userShiftEnterConfigSummary( + configPaths: [String] = loadedCJKScanPaths() + ) -> UserShiftEnterConfigSummary { + var summary = UserShiftEnterConfigSummary() + var recursiveConfigPaths: [String] = [] + + for path in configPaths.map({ NSString(string: $0).expandingTildeInPath }) { + scanShiftEnterConfigFile( + atPath: path, + summary: &summary, + recursiveConfigPaths: &recursiveConfigPaths + ) + } + + var loadedRecursivePaths = Set() + var index = 0 + while index < recursiveConfigPaths.count && !summary.containsExplicitShiftEnterDirective { + let path = NSString(string: recursiveConfigPaths[index]).expandingTildeInPath + index += 1 + + let resolved = (path as NSString).standardizingPath + guard !loadedRecursivePaths.contains(resolved) else { continue } + loadedRecursivePaths.insert(resolved) + + scanShiftEnterConfigFile( + atPath: path, + summary: &summary, + recursiveConfigPaths: &recursiveConfigPaths + ) + } + + return summary + } + /// Returns the top-level config paths that cmux will actually load before /// recursive `config-file` processing. static func loadedCJKScanPaths( @@ -1754,6 +1796,40 @@ class GhosttyApp { } } + private static func scanShiftEnterConfigFile( + atPath path: String, + summary: inout UserShiftEnterConfigSummary, + recursiveConfigPaths: inout [String] + ) { + let resolved = (path as NSString).standardizingPath + guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else { + return + } + let parentDir = (resolved as NSString).deletingLastPathComponent + + for line in contents.components(separatedBy: .newlines) { + guard let entry = parsedConfigEntry(from: line) else { continue } + + switch entry.key { + case "keybind": + guard let value = entry.value else { continue } + summary.recordKeybind(value) + if summary.containsExplicitShiftEnterDirective { + return + } + case "config-file": + guard let value = entry.value else { continue } + applyConfigFileDirective( + value, + parentDir: parentDir, + recursiveConfigPaths: &recursiveConfigPaths + ) + default: + continue + } + } + } + private static func parsedConfigEntry( from rawLine: String ) -> (key: String, value: String?)? { @@ -1806,6 +1882,65 @@ class GhosttyApp { recursiveConfigPaths.append(absolute) } + private static func keybindDirectiveTargetsShiftEnter(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.lowercased() != "clear", + let separatorIndex = trimmed.firstIndex(of: "=") else { + return false + } + + let triggerExpression = String(trimmed[..") { + var part = String(rawPart).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !part.isEmpty else { continue } + + if let slashIndex = part.lastIndex(of: "/") { + part = String(part[part.index(after: slashIndex)...]) + } + + for prefix in ["all:", "global:", "unconsumed:", "performable:"] { + while part.hasPrefix(prefix) { + part.removeFirst(prefix.count) + } + } + + let tokens = part + .split(separator: "+") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard tokens.count == 2, tokens.contains("shift") else { continue } + guard let keyToken = tokens.first(where: { $0 != "shift" }) else { continue } + switch keyToken { + case "enter", "return", "kp_enter", "physical:enter", "physical:return", "physical:kp_enter": + return true + default: + continue + } + } + + return false + } + + static func shouldRemapShiftEnterForTmux( + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags, + isInsideTmux: Bool, + userConfigDefinesShiftEnterBinding: Bool, + ghosttyHasBinding: Bool, + hasMarkedText: Bool + ) -> Bool { + guard isInsideTmux else { return false } + guard !userConfigDefinesShiftEnterBinding else { return false } + guard !ghosttyHasBinding else { return false } + guard !hasMarkedText else { return false } + + let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) + guard normalizedModifiers == [.shift] else { return false } + return keyCode == 36 || keyCode == 76 + } + static func shouldLoadLegacyGhosttyConfig( newConfigFileSize: Int?, legacyConfigFileSize: Int? @@ -5576,16 +5711,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif // Check if this event matches a Ghostty keybinding. - let bindingFlags: ghostty_binding_flags_e? = { - var keyEvent = ghosttyKeyEvent(for: event, surface: surface) - let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? "" - var flags = ghostty_binding_flags_e(0) - let isBinding = text.withCString { ptr in - keyEvent.text = ptr - return ghostty_surface_key_is_binding(surface, keyEvent, &flags) - } - return isBinding ? flags : nil - }() + let bindingFlags = ghosttyBindingFlags(for: event, surface: surface) if let bindingFlags { let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 @@ -5739,6 +5865,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 #endif + if shouldRemapShiftEnterForTmux(event: event, surface: surface) { + terminalSurface?.sendText("\n") + return + } #if DEBUG recordKeyLatency(path: "keyDown", event: event) #endif @@ -6086,6 +6216,47 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return ghostty_surface_key(surface, keyEvent) } + private func ghosttyBindingFlags( + for event: NSEvent, + surface: ghostty_surface_t + ) -> ghostty_binding_flags_e? { + var keyEvent = ghosttyKeyEvent(for: event, surface: surface) + let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? "" + var flags = ghostty_binding_flags_e(0) + let isBinding = text.withCString { ptr in + keyEvent.text = ptr + return ghostty_surface_key_is_binding(surface, keyEvent, &flags) + } + return isBinding ? flags : nil + } + + private func shouldRemapShiftEnterForTmux( + event: NSEvent, + surface: ghostty_surface_t + ) -> Bool { + guard !GhosttyApp.shared.userConfigDefinesShiftEnterBinding else { return false } + let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(event.modifierFlags) + guard normalizedModifiers == [.shift] else { return false } + guard event.keyCode == 36 || event.keyCode == 76 else { return false } + guard let terminalSurface else { return false } + let tabId = terminalSurface.tabId + let panelId = terminalSurface.id + let isInsideTmux = AppDelegate.shared? + .tabManagerFor(tabId: tabId)? + .tabs + .first(where: { $0.id == tabId })? + .panelIsInsideTmux(panelId: panelId) ?? false + let ghosttyHasBinding = ghosttyBindingFlags(for: event, surface: surface) != nil + return GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: event.keyCode, + modifierFlags: event.modifierFlags, + isInsideTmux: isInsideTmux, + userConfigDefinesShiftEnterBinding: false, + ghosttyHasBinding: ghosttyHasBinding, + hasMarkedText: hasMarkedText() + ) + } + #if DEBUG @discardableResult private func sendTimedGhosttyKey( diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index c60a20f9..b0791d49 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2598,6 +2598,15 @@ class TabManager: ObservableObject { tab.updatePanelShellActivityState(panelId: surfaceId, state: state) } + func updateSurfaceTmuxState( + tabId: UUID, + surfaceId: UUID, + isInsideTmux: Bool + ) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.updatePanelTmuxState(panelId: surfaceId, isInsideTmux: isInsideTmux) + } + private func normalizeDirectory(_ directory: String) -> String { let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return directory } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 66d1c723..623b2c44 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -440,8 +440,10 @@ class TerminalController { private let queue = DispatchQueue(label: "com.cmux.socket-fast-path") private var lastReportedDirectories: [SocketSurfaceKey: String] = [:] private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:] + private var lastReportedTmuxStates: [SocketSurfaceKey: Bool] = [:] private let maxTrackedDirectories = 4096 private let maxTrackedShellStates = 4096 + private let maxTrackedTmuxStates = 4096 func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool { let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) @@ -474,6 +476,24 @@ class TerminalController { return true } } + + func shouldPublishTmuxState( + workspaceId: UUID, + panelId: UUID, + isInsideTmux: Bool + ) -> Bool { + let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) + return queue.sync { + if lastReportedTmuxStates[key] == isInsideTmux { + return false + } + if lastReportedTmuxStates.count >= maxTrackedTmuxStates { + lastReportedTmuxStates.removeAll(keepingCapacity: true) + } + lastReportedTmuxStates[key] = isInsideTmux + return true + } + } } private static let socketFastPathState = SocketFastPathState() @@ -545,6 +565,19 @@ class TerminalController { } } + nonisolated static func parseReportedTmuxState( + _ rawState: String + ) -> Bool? { + switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "inside", "tmux", "active", "1", "true", "yes": + return true + case "outside", "none", "inactive", "0", "false", "no", "clear": + return false + default: + return nil + } + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -1804,6 +1837,9 @@ class TerminalController { case "report_shell_state": return reportShellState(args) + case "report_tmux_state": + return reportTmuxState(args) + case "report_pwd": return reportPwd(args) @@ -11117,6 +11153,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_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 @@ -15167,6 +15204,76 @@ class TerminalController { return result } + 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]" + } + guard let isInsideTmux = Self.parseReportedTmuxState(rawState) else { + return "ERROR: Invalid tmux state '\(rawState)' — expected inside or outside" + } + + if let scope = Self.explicitSocketScope(options: parsed.options) { + guard Self.socketFastPathState.shouldPublishTmuxState( + workspaceId: scope.workspaceId, + panelId: scope.panelId, + isInsideTmux: isInsideTmux + ) else { + return "OK" + } + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return } + tabManager.updateSurfaceTmuxState( + tabId: scope.workspaceId, + surfaceId: scope.panelId, + isInsideTmux: isInsideTmux + ) + } + return "OK" + } + + guard let tabManager else { return "ERROR: TabManager not available" } + + 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" + 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)'" + return + } + + tabManager.updateSurfaceTmuxState(tabId: tab.id, surfaceId: surfaceId, isInsideTmux: isInsideTmux) + } + return result + } + private func clearPorts(_ args: String) -> String { let parsed = parseOptions(args) var result = "OK" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ce60e26d..50d69cb8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5578,6 +5578,7 @@ final class Workspace: Identifiable, ObservableObject { }() nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)? private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] + private var panelTmuxStates: [UUID: Bool] = [:] /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. /// Used for stale-session detection: if the PID is dead, the status entry is cleared. var agentPIDs: [String: pid_t] = [:] @@ -6434,6 +6435,24 @@ final class Workspace: Identifiable, ObservableObject { #endif } + func updatePanelTmuxState(panelId: UUID, isInsideTmux: Bool) { + guard panels[panelId] != nil else { return } + let previousState = panelTmuxStates[panelId] ?? false + guard previousState != isInsideTmux else { return } + panelTmuxStates[panelId] = isInsideTmux +#if DEBUG + dlog( + "surface.tmuxState workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) from=\(previousState ? "inside" : "outside") " + + "to=\(isInsideTmux ? "inside" : "outside")" + ) +#endif + } + + func panelIsInsideTmux(panelId: UUID) -> Bool { + panelTmuxStates[panelId] ?? false + } + func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool { Self.resolveCloseConfirmation( shellActivityState: panelShellActivityStates[panelId], @@ -6633,6 +6652,7 @@ final class Workspace: Identifiable, ObservableObject { surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } + panelTmuxStates = panelTmuxStates.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() } @@ -10397,6 +10417,7 @@ extension Workspace: BonsplitDelegate { manualUnreadMarkedAt.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) + panelTmuxStates.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) @@ -10549,6 +10570,7 @@ extension Workspace: BonsplitDelegate { manualUnreadPanelIds.remove(panelId) panelSubscriptions.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) + panelTmuxStates.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index da609bc2..c16f88b5 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3801,10 +3801,6 @@ enum TerminalCopyOnSelectSettings { } } -enum TerminalShiftEnterSettings { - static let overrideConfigLine = #"keybind = shift+enter=text:\x0a"# -} - enum CommandPaletteSwitcherSearchSettings { static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces" static let defaultSearchAllSurfaces = false diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 83d57082..82fe7755 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2495,6 +2495,98 @@ final class GhosttyMouseFocusTests: XCTestCase { } } + func testUserConfigDefinesShiftEnterBindingDetectsDirectBinding() throws { + try withTempConfig("keybind = shift+enter=text:\\x0a\n") { path in + XCTAssertTrue( + GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path]) + ) + } + } + + func testUserConfigDefinesShiftEnterBindingDetectsUnbindInIncludedFile() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-shift-enter-unbind-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("bindings.conf") + try "keybind = shift+enter=unbind\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "config-file = \(included.path)\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue( + GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [main.path]) + ) + } + + func testUserConfigDefinesShiftEnterBindingIgnoresOtherModifierCombinations() throws { + try withTempConfig("keybind = cmd+shift+enter=text:\\x0a\n") { path in + XCTAssertFalse( + GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path]) + ) + } + } + + func testShouldRemapShiftEnterForTmuxOnlyWhenScopedToTmuxWithoutOverrides() { + XCTAssertTrue( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift], + isInsideTmux: true, + userConfigDefinesShiftEnterBinding: false, + ghosttyHasBinding: false, + hasMarkedText: false + ) + ) + + XCTAssertFalse( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift], + isInsideTmux: false, + userConfigDefinesShiftEnterBinding: false, + ghosttyHasBinding: false, + hasMarkedText: false + ) + ) + + XCTAssertFalse( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift], + isInsideTmux: true, + userConfigDefinesShiftEnterBinding: true, + ghosttyHasBinding: false, + hasMarkedText: false + ) + ) + + XCTAssertFalse( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift], + isInsideTmux: true, + userConfigDefinesShiftEnterBinding: false, + ghosttyHasBinding: true, + hasMarkedText: false + ) + ) + + XCTAssertFalse( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift, .command], + isInsideTmux: true, + userConfigDefinesShiftEnterBinding: false, + ghosttyHasBinding: false, + hasMarkedText: false + ) + ) + } + func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws { let appSupport = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)") @@ -2883,6 +2975,24 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertEqual(output, "report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111") } + func testShellIntegrationReportsTmuxStatePayload() throws { + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: "print -r -- \"$(_cmux_report_tmux_state_payload)\"", + extraEnvironment: [ + "TMUX": "/tmp/tmux-current,123,0", + "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999", + ] + ) + + XCTAssertEqual( + output, + "report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999" + ) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, From 9ce4997ced2d6be85270dacaf940f3e33871f6f3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 30 Mar 2026 03:10:20 -0700 Subject: [PATCH 3/5] Fix Shift+Enter tmux follow-up regressions --- .../cmux-bash-integration.bash | 18 +++-- .../cmux-zsh-integration.zsh | 18 +++-- Sources/GhosttyTerminalView.swift | 10 ++- cmuxTests/GhosttyConfigTests.swift | 74 +++++++++++++++++++ 4 files changed, 106 insertions(+), 14 deletions(-) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 99de51f3..0f1826cc 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -61,7 +61,7 @@ _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}" -_CMUX_TMUX_STATE_LAST="${_CMUX_TMUX_STATE_LAST:-}" +_CMUX_TMUX_STATE_SIGNATURE_LAST="${_CMUX_TMUX_STATE_SIGNATURE_LAST:-}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" _CMUX_TMUX_PUSH_SIGNATURE="${_CMUX_TMUX_PUSH_SIGNATURE:-}" @@ -275,6 +275,13 @@ _cmux_report_tmux_state_payload() { printf '%s\n' "report_tmux_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_tmux_state_report_signature() { + local payload="$1" + [[ -n "$payload" ]] || return 0 + [[ -n "$CMUX_SOCKET_PATH" ]] || return 0 + printf '%s\037%s\n' "$CMUX_SOCKET_PATH" "$payload" +} + _cmux_report_tmux_state() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 @@ -282,10 +289,11 @@ _cmux_report_tmux_state() { payload="$(_cmux_report_tmux_state_payload)" [[ -n "$payload" ]] || return 0 - local state="${payload#report_tmux_state }" - state="${state%% *}" - [[ "$_CMUX_TMUX_STATE_LAST" == "$state" ]] && return 0 - _CMUX_TMUX_STATE_LAST="$state" + local signature="" + signature="$(_cmux_tmux_state_report_signature "$payload")" + [[ -n "$signature" ]] || return 0 + [[ "$_CMUX_TMUX_STATE_SIGNATURE_LAST" == "$signature" ]] && return 0 + _CMUX_TMUX_STATE_SIGNATURE_LAST="$signature" { _cmux_send "$payload" } >/dev/null 2>&1 & disown diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index d47bcc3d..87a0b07c 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -74,7 +74,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20 typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_SHELL_ACTIVITY_LAST="" -typeset -g _CMUX_TMUX_STATE_LAST="" +typeset -g _CMUX_TMUX_STATE_SIGNATURE_LAST="" typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 @@ -380,6 +380,13 @@ _cmux_report_tmux_state_payload() { print -r -- "report_tmux_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_tmux_state_report_signature() { + local payload="$1" + [[ -n "$payload" ]] || return 0 + [[ -n "$CMUX_SOCKET_PATH" ]] || return 0 + print -r -- "${CMUX_SOCKET_PATH}"$'\x1f'"${payload}" +} + _cmux_report_tmux_state() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 @@ -387,10 +394,11 @@ _cmux_report_tmux_state() { payload="$(_cmux_report_tmux_state_payload)" [[ -n "$payload" ]] || return 0 - local state="${payload#report_tmux_state }" - state="${state%% *}" - [[ "$_CMUX_TMUX_STATE_LAST" == "$state" ]] && return 0 - _CMUX_TMUX_STATE_LAST="$state" + local signature="" + signature="$(_cmux_tmux_state_report_signature "$payload")" + [[ -n "$signature" ]] || return 0 + [[ "$_CMUX_TMUX_STATE_SIGNATURE_LAST" == "$signature" ]] && return 0 + _CMUX_TMUX_STATE_SIGNATURE_LAST="$signature" _cmux_send_bg "$payload" } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8c09d27d..368e09b4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1672,6 +1672,11 @@ class GhosttyApp { var containsExplicitShiftEnterDirective = false mutating func recordKeybind(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.isEmpty || trimmed == "clear" { + containsExplicitShiftEnterDirective = false + return + } if GhosttyApp.keybindDirectiveTargetsShiftEnter(value) { containsExplicitShiftEnterDirective = true } @@ -1694,7 +1699,7 @@ class GhosttyApp { var loadedRecursivePaths = Set() var index = 0 - while index < recursiveConfigPaths.count && !summary.containsExplicitShiftEnterDirective { + while index < recursiveConfigPaths.count { let path = NSString(string: recursiveConfigPaths[index]).expandingTildeInPath index += 1 @@ -1814,9 +1819,6 @@ class GhosttyApp { case "keybind": guard let value = entry.value else { continue } summary.recordKeybind(value) - if summary.containsExplicitShiftEnterDirective { - return - } case "config-file": guard let value = entry.value else { continue } applyConfigFileDirective( diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 82fe7755..a058b958 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2522,6 +2522,28 @@ final class GhosttyMouseFocusTests: XCTestCase { ) } + func testUserConfigDefinesShiftEnterBindingHonorsLaterClearInIncludedFile() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-shift-enter-clear-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("bindings.conf") + try "keybind = clear\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try """ + keybind = shift+enter=text:\\x0a + config-file = \(included.path) + """ + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertFalse( + GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [main.path]) + ) + } + func testUserConfigDefinesShiftEnterBindingIgnoresOtherModifierCombinations() throws { try withTempConfig("keybind = cmd+shift+enter=text:\\x0a\n") { path in XCTAssertFalse( @@ -2993,6 +3015,58 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { ) } + func testShellIntegrationResendsTmuxStateWhenSocketTargetChanges() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-tmux-state-resend-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let socketA = root.appendingPathComponent("cmux-a.sock").path + let socketB = root.appendingPathComponent("cmux-b.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_a=$! + sleep 0.1 + functions[_cmux_send_bg]='print -r -- "$1"' + _CMUX_TMUX_STATE_SIGNATURE_LAST="" + _cmux_report_tmux_state + kill $server_a >/dev/null 2>&1 + wait $server_a >/dev/null 2>&1 + + export CMUX_SOCKET_PATH="\(socketB)" + 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_b=$! + sleep 0.1 + _cmux_report_tmux_state + kill $server_b >/dev/null 2>&1 + wait $server_b >/dev/null 2>&1 + """, + extraEnvironment: [ + "TMUX": "/tmp/tmux-current,123,0", + "CMUX_SOCKET_PATH": socketA, + "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999", + ] + ) + + 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 + """ + ) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, From f4c99d34f3c3b1618a84cd52dd4b2a10a61d1951 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 30 Mar 2026 03:49:00 -0700 Subject: [PATCH 4/5] Fix tmux Shift+Enter state reporting --- .../cmux-bash-integration.bash | 43 +++++--- .../cmux-zsh-integration.zsh | 20 +++- Sources/TerminalController.swift | 102 ++++++++++++------ cmuxTests/GhosttyConfigTests.swift | 55 +++++++++- ...erminalControllerSocketSecurityTests.swift | 51 +++++++++ 5 files changed, 215 insertions(+), 56 deletions(-) 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 { From 8336aae8657a8f90471e92a603127c97af5da319 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 30 Mar 2026 04:15:23 -0700 Subject: [PATCH 5/5] Use pane TTY fallback for tmux Shift+Enter --- Sources/GhosttyTerminalView.swift | 35 ++++++++++---- Sources/TerminalSSHSessionDetector.swift | 28 ++++++++++- cmuxTests/GhosttyConfigTests.swift | 60 ++++++++++++++++++------ 3 files changed, 97 insertions(+), 26 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 368e09b4..2f3a4628 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1930,12 +1930,10 @@ class GhosttyApp { modifierFlags: NSEvent.ModifierFlags, isInsideTmux: Bool, userConfigDefinesShiftEnterBinding: Bool, - ghosttyHasBinding: Bool, hasMarkedText: Bool ) -> Bool { guard isInsideTmux else { return false } guard !userConfigDefinesShiftEnterBinding else { return false } - guard !ghosttyHasBinding else { return false } guard !hasMarkedText else { return false } let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) @@ -6236,27 +6234,44 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { event: NSEvent, surface: ghostty_surface_t ) -> Bool { - guard !GhosttyApp.shared.userConfigDefinesShiftEnterBinding else { return false } + let userConfigDefinesShiftEnterBinding = GhosttyApp.shared.userConfigDefinesShiftEnterBinding + guard !userConfigDefinesShiftEnterBinding else { return false } let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(event.modifierFlags) guard normalizedModifiers == [.shift] else { return false } guard event.keyCode == 36 || event.keyCode == 76 else { return false } guard let terminalSurface else { return false } let tabId = terminalSurface.tabId let panelId = terminalSurface.id - let isInsideTmux = AppDelegate.shared? + guard let tab = AppDelegate.shared? .tabManagerFor(tabId: tabId)? .tabs - .first(where: { $0.id == tabId })? - .panelIsInsideTmux(panelId: panelId) ?? false - let ghosttyHasBinding = ghosttyBindingFlags(for: event, surface: surface) != nil - return GhosttyApp.shouldRemapShiftEnterForTmux( + .first(where: { $0.id == tabId }) else { + return false + } + let reportedInsideTmux = tab.panelIsInsideTmux(panelId: panelId) + // Shell-side tmux telemetry can lag behind pane focus changes, so fall back to + // the current foreground process on the pane TTY before deciding whether to remap. + let detectedInsideTmux = tab.surfaceTTYNames[panelId].map { + TerminalSSHSessionDetector.isInsideTmux(forTTY: $0) + } ?? false + let isInsideTmux = reportedInsideTmux || detectedInsideTmux + if detectedInsideTmux != reportedInsideTmux { + AppDelegate.shared? + .tabManagerFor(tabId: tabId)? + .updateSurfaceTmuxState( + tabId: tabId, + surfaceId: panelId, + isInsideTmux: detectedInsideTmux + ) + } + let shouldRemap = GhosttyApp.shouldRemapShiftEnterForTmux( keyCode: event.keyCode, modifierFlags: event.modifierFlags, isInsideTmux: isInsideTmux, - userConfigDefinesShiftEnterBinding: false, - ghosttyHasBinding: ghosttyHasBinding, + userConfigDefinesShiftEnterBinding: userConfigDefinesShiftEnterBinding, hasMarkedText: hasMarkedText() ) + return shouldRemap } #if DEBUG diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift index ea111a35..69d9fde7 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -420,6 +420,24 @@ enum TerminalSSHSessionDetector { ) } + static func isInsideTmux(forTTY ttyName: String) -> Bool { + let normalizedTTY = normalizeTTYName(ttyName) + guard !normalizedTTY.isEmpty else { return false } + return isInsideTmuxForTesting( + ttyName: normalizedTTY, + processes: processSnapshots(forTTY: normalizedTTY) + ) + } + + static func isInsideTmuxForTesting( + ttyName: String, + processes: [ProcessSnapshot] + ) -> Bool { + let normalizedTTY = normalizeTTYName(ttyName) + guard !normalizedTTY.isEmpty else { return false } + return processes.contains { isForegroundProcess($0, ttyName: normalizedTTY, executableName: "tmux") } + } + static func detectForTesting( ttyName: String, processes: [ProcessSnapshot], @@ -474,8 +492,16 @@ enum TerminalSSHSessionDetector { } private static func isForegroundSSHProcess(_ process: ProcessSnapshot, ttyName: String) -> Bool { + isForegroundProcess(process, ttyName: ttyName, executableName: "ssh") + } + + private static func isForegroundProcess( + _ process: ProcessSnapshot, + ttyName: String, + executableName: String + ) -> Bool { normalizeTTYName(process.tty) == normalizeTTYName(ttyName) && - process.executableName == "ssh" && + process.executableName == executableName && process.pgid > 0 && process.tpgid > 0 && process.pgid == process.tpgid diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index ec79ad9a..5f52dd43 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2559,7 +2559,6 @@ final class GhosttyMouseFocusTests: XCTestCase { modifierFlags: [.shift], isInsideTmux: true, userConfigDefinesShiftEnterBinding: false, - ghosttyHasBinding: false, hasMarkedText: false ) ) @@ -2570,7 +2569,6 @@ final class GhosttyMouseFocusTests: XCTestCase { modifierFlags: [.shift], isInsideTmux: false, userConfigDefinesShiftEnterBinding: false, - ghosttyHasBinding: false, hasMarkedText: false ) ) @@ -2581,18 +2579,6 @@ final class GhosttyMouseFocusTests: XCTestCase { modifierFlags: [.shift], isInsideTmux: true, userConfigDefinesShiftEnterBinding: true, - ghosttyHasBinding: false, - hasMarkedText: false - ) - ) - - XCTAssertFalse( - GhosttyApp.shouldRemapShiftEnterForTmux( - keyCode: 36, - modifierFlags: [.shift], - isInsideTmux: true, - userConfigDefinesShiftEnterBinding: false, - ghosttyHasBinding: true, hasMarkedText: false ) ) @@ -2603,12 +2589,56 @@ final class GhosttyMouseFocusTests: XCTestCase { modifierFlags: [.shift, .command], isInsideTmux: true, userConfigDefinesShiftEnterBinding: false, - ghosttyHasBinding: false, hasMarkedText: false ) ) } + func testForegroundTmuxProcessOnTTYIsDetected() { + let processes = [ + TerminalSSHSessionDetector.ProcessSnapshot( + pid: 47486, + pgid: 47486, + tpgid: 48365, + tty: "ttys089", + executableName: "login" + ), + TerminalSSHSessionDetector.ProcessSnapshot( + pid: 47487, + pgid: 47487, + tpgid: 48365, + tty: "ttys089", + executableName: "zsh" + ), + TerminalSSHSessionDetector.ProcessSnapshot( + pid: 48365, + pgid: 48365, + tpgid: 48365, + tty: "ttys089", + executableName: "tmux" + ), + ] + + XCTAssertTrue( + TerminalSSHSessionDetector.isInsideTmuxForTesting( + ttyName: "ttys089", + processes: processes + ) + ) + XCTAssertFalse( + TerminalSSHSessionDetector.isInsideTmuxForTesting( + ttyName: "ttys090", + processes: processes + ) + ) + XCTAssertFalse( + TerminalSSHSessionDetector.isInsideTmuxForTesting( + ttyName: "ttys089", + processes: processes.filter { $0.executableName != "tmux" } + ) + ) + } + func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws { let appSupport = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)")