Gate Shift+Enter newline remap to tmux

This commit is contained in:
austinpower1258 2026-03-30 02:55:19 -07:00
parent 540015537d
commit 9080248393
8 changed files with 495 additions and 24 deletions

View file

@ -61,6 +61,7 @@ _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}"
_CMUX_TMUX_STATE_LAST="${_CMUX_TMUX_STATE_LAST:-}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
_CMUX_TMUX_PUSH_SIGNATURE="${_CMUX_TMUX_PUSH_SIGNATURE:-}"
@ -264,6 +265,32 @@ _cmux_report_shell_activity_state() {
} >/dev/null 2>&1 & disown
}
_cmux_report_tmux_state_payload() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local state="outside"
[[ -n "$TMUX" ]] && state="inside"
printf '%s\n' "report_tmux_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
_cmux_report_tmux_state() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
local payload=""
payload="$(_cmux_report_tmux_state_payload)"
[[ -n "$payload" ]] || return 0
local state="${payload#report_tmux_state }"
state="${state%% *}"
[[ "$_CMUX_TMUX_STATE_LAST" == "$state" ]] && return 0
_CMUX_TMUX_STATE_LAST="$state"
{
_cmux_send "$payload"
} >/dev/null 2>&1 & disown
}
_cmux_ports_kick() {
# Lightweight: just tell the app to run a batched scan for this panel.
# The app coalesces kicks across all panels and runs a single ps+lsof.
@ -511,6 +538,7 @@ _cmux_preexec_command() {
fi
_cmux_report_shell_activity_state running
_cmux_report_tmux_state
_cmux_report_tty_once
_cmux_ports_kick
_cmux_stop_pr_poll_loop
@ -527,6 +555,7 @@ _cmux_prompt_command() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_report_shell_activity_state prompt
_cmux_report_tmux_state
local now=$SECONDS
local pwd="$PWD"

View file

@ -74,6 +74,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
typeset -g _CMUX_PORTS_LAST_RUN=0
typeset -g _CMUX_CMD_START=0
typeset -g _CMUX_SHELL_ACTIVITY_LAST=""
typeset -g _CMUX_TMUX_STATE_LAST=""
typeset -g _CMUX_TTY_NAME=""
typeset -g _CMUX_TTY_REPORTED=0
typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0
@ -369,6 +370,30 @@ _cmux_report_shell_activity_state() {
_cmux_send_bg "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
_cmux_report_tmux_state_payload() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local state="outside"
[[ -n "$TMUX" ]] && state="inside"
print -r -- "report_tmux_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
_cmux_report_tmux_state() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
local payload=""
payload="$(_cmux_report_tmux_state_payload)"
[[ -n "$payload" ]] || return 0
local state="${payload#report_tmux_state }"
state="${state%% *}"
[[ "$_CMUX_TMUX_STATE_LAST" == "$state" ]] && return 0
_CMUX_TMUX_STATE_LAST="$state"
_cmux_send_bg "$payload"
}
_cmux_ports_kick() {
# Lightweight: just tell the app to run a batched scan for this panel.
# The app coalesces kicks across all panels and runs a single ps+lsof.
@ -668,6 +693,7 @@ _cmux_preexec() {
_CMUX_CMD_START=$EPOCHSECONDS
_cmux_report_shell_activity_state running
_cmux_report_tmux_state
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
local cmd="${1## }"
@ -693,6 +719,7 @@ _cmux_precmd() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_report_shell_activity_state prompt
_cmux_report_tmux_state
# Handle cases where Ghostty integration initializes after this file.
(( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw

View file

@ -949,6 +949,7 @@ class GhosttyApp {
private var backgroundEventCounter: UInt64 = 0
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
private var defaultBackgroundScopeSource: String = "initialize"
private(set) var userConfigDefinesShiftEnterBinding = false
private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference?
private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher =
// Theme chrome should track terminal theme changes in the same frame.
@ -1382,21 +1383,12 @@ class GhosttyApp {
)
}
private func loadShiftEnterOverride(_ config: ghostty_config_t) {
loadInlineGhosttyConfig(
TerminalShiftEnterSettings.overrideConfigLine,
into: config,
prefix: "cmux-shift-enter",
logLabel: "shift-enter override"
)
}
private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) {
ghostty_config_load_default_files(config)
loadLegacyGhosttyConfigIfNeeded(config)
ghostty_config_load_recursive_files(config)
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
loadShiftEnterOverride(config)
userConfigDefinesShiftEnterBinding = Self.userConfigDefinesShiftEnterBinding()
loadCopyOnSelectOverride(config)
loadCJKFontFallbackIfNeeded(config)
ghostty_config_finalize(config)
@ -1593,6 +1585,12 @@ class GhosttyApp {
) != nil
}
static func userConfigDefinesShiftEnterBinding(
configPaths: [String] = loadedCJKScanPaths()
) -> Bool {
userShiftEnterConfigSummary(configPaths: configPaths).containsExplicitShiftEnterDirective
}
private static func configuredCTFont(
named name: String,
size: CGFloat = 12
@ -1670,6 +1668,50 @@ class GhosttyApp {
return summary
}
private struct UserShiftEnterConfigSummary {
var containsExplicitShiftEnterDirective = false
mutating func recordKeybind(_ value: String) {
if GhosttyApp.keybindDirectiveTargetsShiftEnter(value) {
containsExplicitShiftEnterDirective = true
}
}
}
private static func userShiftEnterConfigSummary(
configPaths: [String] = loadedCJKScanPaths()
) -> UserShiftEnterConfigSummary {
var summary = UserShiftEnterConfigSummary()
var recursiveConfigPaths: [String] = []
for path in configPaths.map({ NSString(string: $0).expandingTildeInPath }) {
scanShiftEnterConfigFile(
atPath: path,
summary: &summary,
recursiveConfigPaths: &recursiveConfigPaths
)
}
var loadedRecursivePaths = Set<String>()
var index = 0
while index < recursiveConfigPaths.count && !summary.containsExplicitShiftEnterDirective {
let path = NSString(string: recursiveConfigPaths[index]).expandingTildeInPath
index += 1
let resolved = (path as NSString).standardizingPath
guard !loadedRecursivePaths.contains(resolved) else { continue }
loadedRecursivePaths.insert(resolved)
scanShiftEnterConfigFile(
atPath: path,
summary: &summary,
recursiveConfigPaths: &recursiveConfigPaths
)
}
return summary
}
/// Returns the top-level config paths that cmux will actually load before
/// recursive `config-file` processing.
static func loadedCJKScanPaths(
@ -1754,6 +1796,40 @@ class GhosttyApp {
}
}
private static func scanShiftEnterConfigFile(
atPath path: String,
summary: inout UserShiftEnterConfigSummary,
recursiveConfigPaths: inout [String]
) {
let resolved = (path as NSString).standardizingPath
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return
}
let parentDir = (resolved as NSString).deletingLastPathComponent
for line in contents.components(separatedBy: .newlines) {
guard let entry = parsedConfigEntry(from: line) else { continue }
switch entry.key {
case "keybind":
guard let value = entry.value else { continue }
summary.recordKeybind(value)
if summary.containsExplicitShiftEnterDirective {
return
}
case "config-file":
guard let value = entry.value else { continue }
applyConfigFileDirective(
value,
parentDir: parentDir,
recursiveConfigPaths: &recursiveConfigPaths
)
default:
continue
}
}
}
private static func parsedConfigEntry(
from rawLine: String
) -> (key: String, value: String?)? {
@ -1806,6 +1882,65 @@ class GhosttyApp {
recursiveConfigPaths.append(absolute)
}
private static func keybindDirectiveTargetsShiftEnter(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
trimmed.lowercased() != "clear",
let separatorIndex = trimmed.firstIndex(of: "=") else {
return false
}
let triggerExpression = String(trimmed[..<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,
ghosttyHasBinding: Bool,
hasMarkedText: Bool
) -> Bool {
guard isInsideTmux else { return false }
guard !userConfigDefinesShiftEnterBinding else { return false }
guard !ghosttyHasBinding else { return false }
guard !hasMarkedText else { return false }
let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
guard normalizedModifiers == [.shift] else { return false }
return keyCode == 36 || keyCode == 76
}
static func shouldLoadLegacyGhosttyConfig(
newConfigFileSize: Int?,
legacyConfigFileSize: Int?
@ -5576,16 +5711,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#endif
// Check if this event matches a Ghostty keybinding.
let bindingFlags: ghostty_binding_flags_e? = {
var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? ""
var flags = ghostty_binding_flags_e(0)
let isBinding = text.withCString { ptr in
keyEvent.text = ptr
return ghostty_surface_key_is_binding(surface, keyEvent, &flags)
}
return isBinding ? flags : nil
}()
let bindingFlags = ghosttyBindingFlags(for: event, surface: surface)
if let bindingFlags {
let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0
@ -5739,6 +5865,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#if DEBUG
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
#endif
if shouldRemapShiftEnterForTmux(event: event, surface: surface) {
terminalSurface?.sendText("\n")
return
}
#if DEBUG
recordKeyLatency(path: "keyDown", event: event)
#endif
@ -6086,6 +6216,47 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return ghostty_surface_key(surface, keyEvent)
}
private func ghosttyBindingFlags(
for event: NSEvent,
surface: ghostty_surface_t
) -> ghostty_binding_flags_e? {
var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? ""
var flags = ghostty_binding_flags_e(0)
let isBinding = text.withCString { ptr in
keyEvent.text = ptr
return ghostty_surface_key_is_binding(surface, keyEvent, &flags)
}
return isBinding ? flags : nil
}
private func shouldRemapShiftEnterForTmux(
event: NSEvent,
surface: ghostty_surface_t
) -> Bool {
guard !GhosttyApp.shared.userConfigDefinesShiftEnterBinding else { return false }
let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(event.modifierFlags)
guard normalizedModifiers == [.shift] else { return false }
guard event.keyCode == 36 || event.keyCode == 76 else { return false }
guard let terminalSurface else { return false }
let tabId = terminalSurface.tabId
let panelId = terminalSurface.id
let isInsideTmux = AppDelegate.shared?
.tabManagerFor(tabId: tabId)?
.tabs
.first(where: { $0.id == tabId })?
.panelIsInsideTmux(panelId: panelId) ?? false
let ghosttyHasBinding = ghosttyBindingFlags(for: event, surface: surface) != nil
return GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: event.keyCode,
modifierFlags: event.modifierFlags,
isInsideTmux: isInsideTmux,
userConfigDefinesShiftEnterBinding: false,
ghosttyHasBinding: ghosttyHasBinding,
hasMarkedText: hasMarkedText()
)
}
#if DEBUG
@discardableResult
private func sendTimedGhosttyKey(

View file

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

View file

@ -440,8 +440,10 @@ class TerminalController {
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
private var lastReportedTmuxStates: [SocketSurfaceKey: Bool] = [:]
private let maxTrackedDirectories = 4096
private let maxTrackedShellStates = 4096
private let maxTrackedTmuxStates = 4096
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
@ -474,6 +476,24 @@ class TerminalController {
return true
}
}
func shouldPublishTmuxState(
workspaceId: UUID,
panelId: UUID,
isInsideTmux: Bool
) -> Bool {
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
return queue.sync {
if lastReportedTmuxStates[key] == isInsideTmux {
return false
}
if lastReportedTmuxStates.count >= maxTrackedTmuxStates {
lastReportedTmuxStates.removeAll(keepingCapacity: true)
}
lastReportedTmuxStates[key] = isInsideTmux
return true
}
}
}
private static let socketFastPathState = SocketFastPathState()
@ -545,6 +565,19 @@ class TerminalController {
}
}
nonisolated static func parseReportedTmuxState(
_ rawState: String
) -> Bool? {
switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "inside", "tmux", "active", "1", "true", "yes":
return true
case "outside", "none", "inactive", "0", "false", "no", "clear":
return false
default:
return nil
}
}
/// Update which window's TabManager receives socket commands.
/// This is used when the user switches between multiple terminal windows.
func setActiveTabManager(_ tabManager: TabManager?) {
@ -1804,6 +1837,9 @@ class TerminalController {
case "report_shell_state":
return reportShellState(args)
case "report_tmux_state":
return reportTmuxState(args)
case "report_pwd":
return reportPwd(args)
@ -11117,6 +11153,7 @@ class TerminalController {
report_tty <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] - 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
@ -15167,6 +15204,76 @@ class TerminalController {
return result
}
private func reportTmuxState(_ args: String) -> String {
let parsed = parseOptions(args)
guard let rawState = parsed.positional.first, !rawState.isEmpty else {
return "ERROR: Missing tmux state — usage: report_tmux_state <inside|outside> [--tab=X] [--panel=Y]"
}
guard let isInsideTmux = Self.parseReportedTmuxState(rawState) else {
return "ERROR: Invalid tmux state '\(rawState)' — expected inside or outside"
}
if let scope = Self.explicitSocketScope(options: parsed.options) {
guard Self.socketFastPathState.shouldPublishTmuxState(
workspaceId: scope.workspaceId,
panelId: scope.panelId,
isInsideTmux: isInsideTmux
) else {
return "OK"
}
DispatchQueue.main.async {
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return }
tabManager.updateSurfaceTmuxState(
tabId: scope.workspaceId,
surfaceId: scope.panelId,
isInsideTmux: isInsideTmux
)
}
return "OK"
}
guard let tabManager else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_tmux_state <inside|outside> [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tabManager.updateSurfaceTmuxState(tabId: tab.id, surfaceId: surfaceId, isInsideTmux: isInsideTmux)
}
return result
}
private func clearPorts(_ args: String) -> String {
let parsed = parseOptions(args)
var result = "OK"

View file

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

View file

@ -3801,10 +3801,6 @@ enum TerminalCopyOnSelectSettings {
}
}
enum TerminalShiftEnterSettings {
static let overrideConfigLine = #"keybind = shift+enter=text:\x0a"#
}
enum CommandPaletteSwitcherSearchSettings {
static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces"
static let defaultSearchAllSurfaces = false

View file

@ -2495,6 +2495,98 @@ final class GhosttyMouseFocusTests: XCTestCase {
}
}
func testUserConfigDefinesShiftEnterBindingDetectsDirectBinding() throws {
try withTempConfig("keybind = shift+enter=text:\\x0a\n") { path in
XCTAssertTrue(
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path])
)
}
}
func testUserConfigDefinesShiftEnterBindingDetectsUnbindInIncludedFile() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-shift-enter-unbind-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("bindings.conf")
try "keybind = shift+enter=unbind\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "config-file = \(included.path)\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [main.path])
)
}
func testUserConfigDefinesShiftEnterBindingIgnoresOtherModifierCombinations() throws {
try withTempConfig("keybind = cmd+shift+enter=text:\\x0a\n") { path in
XCTAssertFalse(
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path])
)
}
}
func testShouldRemapShiftEnterForTmuxOnlyWhenScopedToTmuxWithoutOverrides() {
XCTAssertTrue(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift],
isInsideTmux: true,
userConfigDefinesShiftEnterBinding: false,
ghosttyHasBinding: false,
hasMarkedText: false
)
)
XCTAssertFalse(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift],
isInsideTmux: false,
userConfigDefinesShiftEnterBinding: false,
ghosttyHasBinding: false,
hasMarkedText: false
)
)
XCTAssertFalse(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift],
isInsideTmux: true,
userConfigDefinesShiftEnterBinding: true,
ghosttyHasBinding: false,
hasMarkedText: false
)
)
XCTAssertFalse(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift],
isInsideTmux: true,
userConfigDefinesShiftEnterBinding: false,
ghosttyHasBinding: true,
hasMarkedText: false
)
)
XCTAssertFalse(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift, .command],
isInsideTmux: true,
userConfigDefinesShiftEnterBinding: false,
ghosttyHasBinding: false,
hasMarkedText: false
)
)
}
func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws {
let appSupport = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)")
@ -2883,6 +2975,24 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
XCTAssertEqual(output, "report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111")
}
func testShellIntegrationReportsTmuxStatePayload() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: "print -r -- \"$(_cmux_report_tmux_state_payload)\"",
extraEnvironment: [
"TMUX": "/tmp/tmux-current,123,0",
"CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111",
"CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999",
]
)
XCTAssertEqual(
output,
"report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999"
)
}
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
try runInteractiveZsh(
cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,