From 9080248393a8c1effcb2ec86d75a6ba26bdc74b4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 30 Mar 2026 02:55:19 -0700 Subject: [PATCH] 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,