Gate Shift+Enter newline remap to tmux
This commit is contained in:
parent
540015537d
commit
9080248393
8 changed files with 495 additions and 24 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue