diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 1d54c670..7fccc049 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_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:-}" @@ -264,6 +265,47 @@ _cmux_report_shell_activity_state() { } >/dev/null 2>&1 & disown } +_cmux_report_tmux_state_payload() { + [[ -n "$CMUX_TAB_ID" ]] || return 0 + + local state="outside" + [[ -n "$TMUX" ]] && state="inside" + + 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() { + 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 + + local payload="" + payload="$(_cmux_report_tmux_state_payload)" + [[ -n "$payload" ]] || return 0 + + 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 +} + _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. @@ -501,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 @@ -510,8 +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 } @@ -525,8 +570,21 @@ _cmux_prompt_command() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 + + 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 - _cmux_report_shell_activity_state prompt local now=$SECONDS local pwd="$PWD" @@ -543,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 273d062c..63533d48 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_SIGNATURE_LAST="" typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 @@ -369,6 +370,45 @@ _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 + + local state="outside" + [[ -n "$TMUX" ]] && state="inside" + + 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() { + 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 + + local payload="" + payload="$(_cmux_report_tmux_state_payload)" + [[ -n "$payload" ]] || return 0 + + 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" +} + _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 +708,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## }" @@ -691,8 +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 # Handle cases where Ghostty integration initializes after this file. (( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw @@ -704,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/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0c3068b0..e7027e80 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. @@ -1387,6 +1388,7 @@ class GhosttyApp { loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) loadCmuxAppSupportGhosttyConfigIfNeeded(config) + userConfigDefinesShiftEnterBinding = Self.userConfigDefinesShiftEnterBinding() loadCopyOnSelectOverride(config) loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) @@ -1583,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 @@ -1660,6 +1668,55 @@ class GhosttyApp { return summary } + private struct UserShiftEnterConfigSummary { + 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 + } + } + } + + 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 { + 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( @@ -1744,6 +1801,37 @@ 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) + 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?)? { @@ -1796,6 +1884,63 @@ 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, + hasMarkedText: Bool + ) -> Bool { + guard isInsideTmux else { return false } + guard !userConfigDefinesShiftEnterBinding 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? @@ -5613,16 +5758,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 @@ -5777,6 +5913,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 @@ -6124,6 +6264,64 @@ 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 { + 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 + guard let tab = AppDelegate.shared? + .tabManagerFor(tabId: tabId)? + .tabs + .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: userConfigDefinesShiftEnterBinding, + hasMarkedText: hasMarkedText() + ) + return shouldRemap + } + #if DEBUG @discardableResult private func sendTimedGhosttyKey( diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 4e69d49e..446a0a7a 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..a5b349dd 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() @@ -500,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) @@ -545,6 +601,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 +1873,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 +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] [--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 @@ -15167,6 +15240,80 @@ 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] [--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, + 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" + } + + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "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 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 + } + + tab.updatePanelTmuxState(panelId: surfaceId, isInsideTmux: isInsideTmux) + } + return "OK" + } + private func clearPorts(_ args: String) -> String { let parsed = parseOptions(args) var result = "OK" 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/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/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 83d57082..5f52dd43 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2495,6 +2495,150 @@ 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 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( + GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path]) + ) + } + } + + func testShouldRemapShiftEnterForTmuxOnlyWhenScopedToTmuxWithoutOverrides() { + XCTAssertTrue( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift], + isInsideTmux: true, + userConfigDefinesShiftEnterBinding: false, + hasMarkedText: false + ) + ) + + XCTAssertFalse( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift], + isInsideTmux: false, + userConfigDefinesShiftEnterBinding: false, + hasMarkedText: false + ) + ) + + XCTAssertFalse( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift], + isInsideTmux: true, + userConfigDefinesShiftEnterBinding: true, + hasMarkedText: false + ) + ) + + XCTAssertFalse( + GhosttyApp.shouldRemapShiftEnterForTmux( + keyCode: 36, + modifierFlags: [.shift, .command], + isInsideTmux: true, + userConfigDefinesShiftEnterBinding: 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)") @@ -2883,6 +3027,123 @@ 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: """ + _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", + "CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999", + ] + ) + + XCTAssertEqual( + output, + "report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999" + ) + } + + 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_TTY_NAME=ttys999 + _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 --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 + """ + ) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, 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 {