Fix tmux Shift+Enter state reporting

This commit is contained in:
austinpower1258 2026-03-30 03:49:00 -07:00
parent 9ce4997ced
commit f4c99d34f3
5 changed files with 215 additions and 56 deletions

View file

@ -267,12 +267,19 @@ _cmux_report_shell_activity_state() {
_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"
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() {
@ -536,7 +543,6 @@ _cmux_preexec_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
@ -545,9 +551,12 @@ _cmux_preexec_command() {
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
_cmux_report_shell_activity_state running
if [[ -n "$CMUX_PANEL_ID" ]]; then
_cmux_report_shell_activity_state running
fi
_cmux_report_tmux_state
_cmux_report_tty_once
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_ports_kick
_cmux_stop_pr_poll_loop
}
@ -561,9 +570,21 @@ _cmux_prompt_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_report_shell_activity_state prompt
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
local now=$SECONDS
local pwd="$PWD"
@ -580,16 +601,6 @@ _cmux_prompt_command() {
fi
fi
# Resolve TTY name once.
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
t="$(tty 2>/dev/null || true)"
t="${t##*/}"
[[ "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
_cmux_report_tty_once
# CWD: keep the app in sync with the actual shell directory.
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
_CMUX_PWD_LAST_PWD="$pwd"

View file

@ -372,12 +372,19 @@ _cmux_report_shell_activity_state() {
_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"
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() {
@ -725,9 +732,6 @@ _cmux_precmd() {
# Skip if socket doesn't exist yet
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_report_shell_activity_state prompt
_cmux_report_tmux_state
# Handle cases where Ghostty integration initializes after this file.
(( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw
@ -739,8 +743,14 @@ _cmux_precmd() {
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
if [[ -n "$CMUX_PANEL_ID" ]]; then
_cmux_report_shell_activity_state prompt
fi
_cmux_report_tmux_state
_cmux_report_tty_once
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local now=$EPOCHSECONDS
local pwd="$PWD"
local cmd_start="$_CMUX_CMD_START"

View file

@ -520,6 +520,42 @@ class TerminalController {
return trimmed
}
nonisolated static func normalizedReportedTTYName(_ raw: String?) -> String? {
guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty,
trimmed != "not a tty" else {
return nil
}
let components = trimmed.split(separator: "/")
if let last = components.last, !last.isEmpty {
return String(last)
}
return trimmed
}
static func resolvePanelIdByTTY(_ ttyName: String?, in tab: Tab) -> UUID? {
guard let ttyName = normalizedReportedTTYName(ttyName) else {
return nil
}
if let focusedPanelId = tab.focusedPanelId,
normalizedReportedTTYName(tab.surfaceTTYNames[focusedPanelId]) == ttyName,
tab.panels[focusedPanelId] != nil {
return focusedPanelId
}
let matches = tab.surfaceTTYNames.compactMap { (panelId, candidateTTY) -> UUID? in
guard tab.panels[panelId] != nil,
normalizedReportedTTYName(candidateTTY) == ttyName else {
return nil
}
return panelId
}
guard matches.count == 1 else { return nil }
return matches[0]
}
nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@ -11153,7 +11189,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_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
@ -15207,12 +15243,27 @@ class TerminalController {
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]"
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,
@ -15232,46 +15283,35 @@ class TerminalController {
return "OK"
}
guard let tabManager else { return "ERROR: TabManager not available" }
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
guard let targetTabId = tabResolution.tabId else {
return tabResolution.error ?? "ERROR: No tab selected"
}
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"
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 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)'"
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
}
tabManager.updateSurfaceTmuxState(tabId: tab.id, surfaceId: surfaceId, isInsideTmux: isInsideTmux)
tab.updatePanelTmuxState(panelId: surfaceId, isInsideTmux: isInsideTmux)
}
return result
return "OK"
}
private func clearPorts(_ args: String) -> String {

View file

@ -3001,7 +3001,10 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: "print -r -- \"$(_cmux_report_tmux_state_payload)\"",
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",
@ -3011,7 +3014,7 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
XCTAssertEqual(
output,
"report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999"
"report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999"
)
}
@ -3035,6 +3038,7 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
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
@ -3061,8 +3065,51 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
XCTAssertEqual(
output,
"""
report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999
report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999
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
"""
)
}

View file

@ -254,6 +254,41 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
XCTAssertTrue(manager.tabs.contains(where: { $0.id == pinnedWorkspace.id }))
}
func testReportTmuxStateResolvesPanelByTTY() throws {
let socketPath = makeSocketPath("tmux-tty")
let manager = TabManager()
let workspace = manager.addWorkspace(select: true)
guard let focusedPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with a focused panel")
return
}
guard let targetPanel = workspace.newTerminalSplit(from: focusedPanelId, orientation: .horizontal) else {
XCTFail("Expected split panel to be created")
return
}
workspace.focusPanel(focusedPanelId)
workspace.surfaceTTYNames[targetPanel.id] = "/dev/ttys777"
TerminalController.shared.start(
tabManager: manager,
socketPath: socketPath,
accessMode: .allowAll
)
try waitForSocket(at: socketPath)
let responses = try sendCommands(
["report_tmux_state inside --tab=\(workspace.id.uuidString) --tty=ttys777"],
to: socketPath
)
XCTAssertEqual(responses, ["OK"])
try waitForCondition("tmux state routed by tty") {
workspace.panelIsInsideTmux(panelId: targetPanel.id)
}
XCTAssertFalse(workspace.panelIsInsideTmux(panelId: focusedPanelId))
}
private func waitForSocket(at path: String, timeout: TimeInterval = 5.0) throws {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
@ -268,6 +303,22 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT))
}
private func waitForCondition(
_ description: String,
timeout: TimeInterval = 5.0,
condition: @escaping () -> Bool
) throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return
}
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
XCTFail("Timed out waiting for \(description)")
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT))
}
private func socketMode(at path: String) throws -> UInt16 {
var fileInfo = stat()
guard lstat(path, &fileInfo) == 0 else {