diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 7fccc049..1d54c670 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -61,7 +61,6 @@ _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:-}" @@ -265,47 +264,6 @@ _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. @@ -543,6 +501,7 @@ _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 @@ -551,12 +510,8 @@ _cmux_preexec_command() { [[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" fi - if [[ -n "$CMUX_PANEL_ID" ]]; then - _cmux_report_shell_activity_state running - fi - _cmux_report_tmux_state + _cmux_report_shell_activity_state running _cmux_report_tty_once - [[ -n "$CMUX_PANEL_ID" ]] || return 0 _cmux_ports_kick _cmux_stop_pr_poll_loop } @@ -570,21 +525,8 @@ _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" @@ -601,6 +543,16 @@ _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 63533d48..273d062c 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -74,7 +74,6 @@ 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 @@ -370,45 +369,6 @@ _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. @@ -708,7 +668,6 @@ _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## }" @@ -732,6 +691,8 @@ _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 @@ -743,14 +704,8 @@ _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 d7d920aa..db7e91eb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -949,7 +949,6 @@ 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. @@ -1394,7 +1393,6 @@ class GhosttyApp { loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) loadCmuxAppSupportGhosttyConfigIfNeeded(config) - userConfigDefinesShiftEnterBinding = Self.userConfigDefinesShiftEnterBinding() loadCopyOnSelectOverride(config) loadCJKFontFallbackIfNeeded(config) // cmux provides the terminal background via backgroundView (CALayer) @@ -1600,12 +1598,6 @@ class GhosttyApp { ) != nil } - static func userConfigDefinesShiftEnterBinding( - configPaths: [String] = loadedCJKScanPaths() - ) -> Bool { - userShiftEnterConfigSummary(configPaths: configPaths).containsExplicitShiftEnterDirective - } - private static func configuredCTFont( named name: String, size: CGFloat = 12 @@ -1683,55 +1675,6 @@ 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( @@ -1816,37 +1759,6 @@ 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?)? { @@ -1899,63 +1811,6 @@ 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? @@ -5773,7 +5628,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif // Check if this event matches a Ghostty keybinding. - let bindingFlags = ghosttyBindingFlags(for: event, surface: surface) + 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 + }() if let bindingFlags { let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 @@ -5928,10 +5792,6 @@ 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 @@ -6279,64 +6139,6 @@ 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 446a0a7a..4e69d49e 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2598,15 +2598,6 @@ 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 a5b349dd..66d1c723 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -440,10 +440,8 @@ 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) @@ -476,24 +474,6 @@ 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() @@ -520,42 +500,6 @@ 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) @@ -601,19 +545,6 @@ 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?) { @@ -1873,9 +1804,6 @@ class TerminalController { case "report_shell_state": return reportShellState(args) - case "report_tmux_state": - return reportTmuxState(args) - case "report_pwd": return reportPwd(args) @@ -11189,7 +11117,6 @@ 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 @@ -15240,80 +15167,6 @@ 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 69d9fde7..ea111a35 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -420,24 +420,6 @@ 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], @@ -492,16 +474,8 @@ 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 == executableName && + process.executableName == "ssh" && process.pgid > 0 && process.tpgid > 0 && process.pgid == process.tpgid diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 50d69cb8..ce60e26d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5578,7 +5578,6 @@ 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] = [:] @@ -6435,24 +6434,6 @@ 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], @@ -6652,7 +6633,6 @@ 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() } @@ -10417,7 +10397,6 @@ 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) @@ -10570,7 +10549,6 @@ 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 5f52dd43..83d57082 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2495,150 +2495,6 @@ 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)") @@ -3027,123 +2883,6 @@ 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 c995bc6f..8f4232ce 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -254,41 +254,6 @@ 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 @@ -303,22 +268,6 @@ 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 {