Revert "Map Shift+Enter to raw newline in Ghostty"

This commit is contained in:
Austin Wang 2026-03-30 21:56:59 -07:00 committed by GitHub
parent 112358ed01
commit a5120be321
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 26 additions and 833 deletions

View file

@ -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_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"

View file

@ -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"

View file

@ -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<String>()
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[..<separatorIndex])
for rawPart in triggerExpression.split(separator: ">") {
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(

View file

@ -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 }

View file

@ -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 <tty_name> [--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 <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command
report_tmux_state <inside|outside> [--tab=X] [--panel=Y] [--tty=Z] - Report whether the shell is currently inside tmux
report_pwd <path> [--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 <inside|outside> [--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 <inside|outside> [--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"

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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 {