Fix tmux Shift+Enter state reporting
This commit is contained in:
parent
9ce4997ced
commit
f4c99d34f3
5 changed files with 215 additions and 56 deletions
|
|
@ -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
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue