diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0f9246cf7bbf..9de1ca188d23 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1863,12 +1863,36 @@ struct CMUXCLI { case "trigger-flash": let tfWsFlag = optionValue(commandArgs, name: "--workspace") - let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let surfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (tfWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + let explicitWorkspaceArg = tfWsFlag + let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil + let callerWorkspaceArg = preferTTYFallback + ? nil + : (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg + let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") + let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && windowId == nil + ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + : nil + let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + let wsId = try { + if explicitWorkspaceArg != nil { + return try normalizeWorkspaceHandle(workspaceArg, client: client) + } + return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client) + }() if let wsId { params["workspace_id"] = wsId } - let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + let sfId = try { + if explicitSurfaceArg != nil { + return try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + } + guard let wsId else { return nil } + return try resolveSurfaceIdAllowingFallback( + surfaceArg, + workspaceId: wsId, + client: client + ) + }() if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.trigger_flash", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) @@ -2062,12 +2086,34 @@ struct CMUXCLI { let subtitle = optionValue(commandArgs, name: "--subtitle") ?? "" let body = optionValue(commandArgs, name: "--body") ?? "" - let notifyWsFlag = optionValue(commandArgs, name: "--workspace") - let workspaceArg = notifyWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (notifyWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) - - let targetWorkspace = try resolveWorkspaceId(workspaceArg, client: client) - let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) + let explicitWorkspaceArg = optionValue(commandArgs, name: "--workspace") + let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil + let callerWorkspaceArg = preferTTYFallback + ? nil + : (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg + let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") + let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && windowId == nil + ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + : nil + let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg + + let targetWorkspace = try { + if explicitWorkspaceArg != nil { + return try resolveWorkspaceId(workspaceArg, client: client) + } + return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client) + }() + let targetSurface = try { + if explicitSurfaceArg != nil { + return try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) + } + return try resolveSurfaceIdAllowingFallback( + surfaceArg, + workspaceId: targetWorkspace, + client: client + ) + }() let payload = "\(title)|\(subtitle)|\(body)" let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) @@ -10482,13 +10528,7 @@ struct CMUXCLI { } private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String { - if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) { - let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate]) - if probe != nil { - return candidate - } - } - return try resolveWorkspaceId(nil, client: client) + try resolveWorkspaceIdAllowingFallback(raw, client: client) } private func resolveSurfaceIdForClaudeHook( @@ -10496,12 +10536,125 @@ struct CMUXCLI { workspaceId: String, client: SocketClient ) throws -> String { - if let raw, !raw.isEmpty, let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client) { + try resolveSurfaceIdAllowingFallback(raw, workspaceId: workspaceId, client: client) + } + + private func resolveWorkspaceIdAllowingFallback( + _ raw: String?, + client: SocketClient + ) throws -> String { + if let raw, + !raw.isEmpty, + let candidate = try? resolveWorkspaceId(raw, client: client), + (try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])) != nil { return candidate } + if let callerWorkspaceId = resolveCallerWorkspaceIdByTTY(client: client), + (try? client.sendV2(method: "surface.list", params: ["workspace_id": callerWorkspaceId])) != nil { + return callerWorkspaceId + } + return try resolveWorkspaceId(nil, client: client) + } + + private func resolveSurfaceIdAllowingFallback( + _ raw: String?, + workspaceId: String, + client: SocketClient + ) throws -> String { + if let raw, + !raw.isEmpty, + let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client), + let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) { + let items = listed["surfaces"] as? [[String: Any]] ?? [] + if items.contains(where: { + ($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate + }) { + return candidate + } + } + if let callerSurfaceId = resolveCallerSurfaceIdByTTY(workspaceId: workspaceId, client: client), + let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) { + let items = listed["surfaces"] as? [[String: Any]] ?? [] + if items.contains(where: { + ($0["id"] as? String) == callerSurfaceId || ($0["ref"] as? String) == callerSurfaceId + }) { + return callerSurfaceId + } + } return try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) } + private struct CallerTerminalBinding { + let workspaceId: String + let surfaceId: String + } + + private func resolveCallerWorkspaceIdByTTY(client: SocketClient) -> String? { + resolveCallerTerminalBindingByTTY(client: client)?.workspaceId + } + + private func resolveCallerSurfaceIdByTTY(workspaceId: String, client: SocketClient) -> String? { + guard let binding = resolveCallerTerminalBindingByTTY(client: client), + binding.workspaceId == workspaceId else { + return nil + } + return binding.surfaceId + } + + private func resolveCallerTerminalBindingByTTY(client: SocketClient) -> CallerTerminalBinding? { + guard let ttyName = resolveCallerTTYName() else { + return nil + } + guard let payload = try? client.sendV2(method: "debug.terminals") else { + return nil + } + let terminals = payload["terminals"] as? [[String: Any]] ?? [] + for terminal in terminals { + guard normalizedTTYName(terminal["tty"] as? String) == ttyName, + let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String), + let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else { + continue + } + return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId) + } + return nil + } + + private func resolveCallerTTYName() -> String? { + let env = ProcessInfo.processInfo.environment + for key in ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME", "TTY", "SSH_TTY"] { + if let ttyName = normalizedTTYName(env[key]) { + return ttyName + } + } + for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] { + if let rawTTYName = ttyname(fileDescriptor), + let ttyName = normalizedTTYName(String(cString: rawTTYName)) { + return ttyName + } + } + return nil + } + + private func normalizedTTYName(_ raw: String?) -> String? { + guard let trimmed = normalizedHandleValue(raw == "not a tty" ? nil : raw) else { + return nil + } + let components = trimmed.split(separator: "/") + if let last = components.last, !last.isEmpty { + return String(last) + } + return trimmed + } + + private func normalizedHandleValue(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty else { + return nil + } + return raw + } + private func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput { let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index fe66dbfaec07..db9ae9f32a82 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -54,6 +54,129 @@ _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" +_CMUX_TMUX_PUSH_SIGNATURE="${_CMUX_TMUX_PUSH_SIGNATURE:-}" +_CMUX_TMUX_PULL_SIGNATURE="${_CMUX_TMUX_PULL_SIGNATURE:-}" +_CMUX_TMUX_SYNC_KEYS=( + CMUX_BUNDLED_CLI_PATH + CMUX_BUNDLE_ID + CMUXD_UNIX_PATH + CMUXTERM_REPO_ROOT + CMUX_DEBUG_LOG + CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION + CMUX_PORT + CMUX_PORT_END + CMUX_PORT_RANGE + CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD + CMUX_SHELL_INTEGRATION + CMUX_SHELL_INTEGRATION_DIR + CMUX_SOCKET_ENABLE + CMUX_SOCKET_MODE + CMUX_SOCKET_PATH + CMUX_TAB_ID + CMUX_TAG + CMUX_WORKSPACE_ID +) +_CMUX_TMUX_SURFACE_SCOPED_KEYS=( + CMUX_PANEL_ID + CMUX_SURFACE_ID +) + +_cmux_tmux_sync_key_is_managed() { + local candidate="$1" + local key + for key in "${_CMUX_TMUX_SYNC_KEYS[@]}"; do + [[ "$key" == "$candidate" ]] && return 0 + done + return 1 +} + +_cmux_tmux_shell_env_signature() { + local key value first=1 + for key in "${_CMUX_TMUX_SYNC_KEYS[@]}"; do + value="${!key}" + [[ -n "$value" ]] || continue + if (( first )); then + printf '%s=%s' "$key" "$value" + first=0 + else + printf '\037%s=%s' "$key" "$value" + fi + done +} + +_cmux_tmux_publish_cmux_environment() { + [[ -z "$TMUX" ]] || return 0 + command -v tmux >/dev/null 2>&1 || return 0 + + local signature + signature="$(_cmux_tmux_shell_env_signature)" + [[ -n "$signature" ]] || return 0 + [[ "$signature" == "$_CMUX_TMUX_PUSH_SIGNATURE" ]] && return 0 + + local key value + for key in "${_CMUX_TMUX_SYNC_KEYS[@]}"; do + value="${!key}" + [[ -n "$value" ]] || continue + tmux set-environment -g "$key" "$value" >/dev/null 2>&1 || return 0 + done + + for key in "${_CMUX_TMUX_SURFACE_SCOPED_KEYS[@]}"; do + tmux set-environment -gu "$key" >/dev/null 2>&1 || return 0 + done + + _CMUX_TMUX_PUSH_SIGNATURE="$signature" +} + +_cmux_tmux_refresh_cmux_environment() { + [[ -n "$TMUX" ]] || return 0 + command -v tmux >/dev/null 2>&1 || return 0 + + local output filtered line key value did_change=0 + output="$(tmux show-environment -g 2>/dev/null)" || return 0 + + while IFS= read -r line; do + [[ "$line" == CMUX_* ]] || continue + key="${line%%=*}" + _cmux_tmux_sync_key_is_managed "$key" || continue + filtered+="${line}"$'\n' + done <<< "$output" + + [[ -n "$filtered" ]] || return 0 + [[ "$filtered" == "$_CMUX_TMUX_PULL_SIGNATURE" ]] && return 0 + + while IFS= read -r line; do + [[ "$line" == CMUX_* ]] || continue + key="${line%%=*}" + _cmux_tmux_sync_key_is_managed "$key" || continue + value="${line#*=}" + if [[ "${!key}" != "$value" ]]; then + printf -v "$key" '%s' "$value" + export "$key" + did_change=1 + fi + done <<< "$filtered" + + _CMUX_TMUX_PULL_SIGNATURE="$filtered" + if (( did_change )); then + _CMUX_TTY_REPORTED=0 + _CMUX_SHELL_ACTIVITY_LAST="" + _CMUX_PWD_LAST_PWD="" + _CMUX_GIT_LAST_PWD="" + _CMUX_GIT_HEAD_LAST_PWD="" + _CMUX_GIT_HEAD_PATH="" + _CMUX_GIT_HEAD_SIGNATURE="" + _CMUX_PR_FORCE=1 + _cmux_stop_pr_poll_loop + fi +} + +_cmux_tmux_sync_cmux_environment() { + if [[ -n "$TMUX" ]]; then + _cmux_tmux_refresh_cmux_environment + else + _cmux_tmux_publish_cmux_environment + fi +} _cmux_git_resolve_head_path() { # Resolve the HEAD file path without invoking git (fast; works for worktrees). @@ -90,17 +213,32 @@ _cmux_git_head_signature() { printf '%s\n' "$line" } +_cmux_report_tty_payload() { + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$_CMUX_TTY_NAME" ]] || return 0 + + local payload="report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID" + if [[ -z "$TMUX" ]]; then + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + payload+=" --panel=$CMUX_PANEL_ID" + fi + + printf '%s\n' "$payload" +} + _cmux_report_tty_once() { # Send the TTY name to the app once per session so the batched port scanner # knows which TTY belongs to this panel. (( _CMUX_TTY_REPORTED )) && return 0 [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 - [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 - [[ -n "$_CMUX_TTY_NAME" ]] || return 0 + + local payload="" + payload="$(_cmux_report_tty_payload)" + [[ -n "$payload" ]] || return 0 + _CMUX_TTY_REPORTED=1 { - _cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + _cmux_send "$payload" } >/dev/null 2>&1 & disown } @@ -348,6 +486,8 @@ _cmux_bash_cleanup() { } _cmux_preexec_command() { + _cmux_tmux_sync_cmux_environment + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 @@ -370,6 +510,8 @@ _cmux_bash_preexec_hook() { } _cmux_prompt_command() { + _cmux_tmux_sync_cmux_environment + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index b468984cdbed..1364f4a6e70d 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -60,6 +60,129 @@ typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 typeset -g _CMUX_WINCH_GUARD_INSTALLED=0 +typeset -g _CMUX_TMUX_PUSH_SIGNATURE="" +typeset -g _CMUX_TMUX_PULL_SIGNATURE="" +typeset -ga _CMUX_TMUX_SYNC_KEYS=( + CMUX_BUNDLED_CLI_PATH + CMUX_BUNDLE_ID + CMUXD_UNIX_PATH + CMUXTERM_REPO_ROOT + CMUX_DEBUG_LOG + CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION + CMUX_PORT + CMUX_PORT_END + CMUX_PORT_RANGE + CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD + CMUX_SHELL_INTEGRATION + CMUX_SHELL_INTEGRATION_DIR + CMUX_SOCKET_ENABLE + CMUX_SOCKET_MODE + CMUX_SOCKET_PATH + CMUX_TAB_ID + CMUX_TAG + CMUX_WORKSPACE_ID +) +typeset -ga _CMUX_TMUX_SURFACE_SCOPED_KEYS=( + CMUX_PANEL_ID + CMUX_SURFACE_ID +) + +_cmux_tmux_sync_key_is_managed() { + local candidate="$1" + local key + for key in "${_CMUX_TMUX_SYNC_KEYS[@]}"; do + [[ "$key" == "$candidate" ]] && return 0 + done + return 1 +} + +_cmux_tmux_shell_env_signature() { + local key value + local -a parts + for key in "${_CMUX_TMUX_SYNC_KEYS[@]}"; do + value="${(P)key}" + [[ -n "$value" ]] || continue + parts+=("${key}=${value}") + done + print -r -- "${(j:\x1f:)parts}" +} + +_cmux_tmux_publish_cmux_environment() { + [[ -z "$TMUX" ]] || return 0 + command -v tmux >/dev/null 2>&1 || return 0 + + local signature + signature="$(_cmux_tmux_shell_env_signature)" + [[ -n "$signature" ]] || return 0 + [[ "$signature" == "$_CMUX_TMUX_PUSH_SIGNATURE" ]] && return 0 + + local key value + for key in "${_CMUX_TMUX_SYNC_KEYS[@]}"; do + value="${(P)key}" + [[ -n "$value" ]] || continue + tmux set-environment -g "$key" "$value" >/dev/null 2>&1 || return 0 + done + + for key in "${_CMUX_TMUX_SURFACE_SCOPED_KEYS[@]}"; do + tmux set-environment -gu "$key" >/dev/null 2>&1 || return 0 + done + + _CMUX_TMUX_PUSH_SIGNATURE="$signature" +} + +_cmux_tmux_refresh_cmux_environment() { + [[ -n "$TMUX" ]] || return 0 + command -v tmux >/dev/null 2>&1 || return 0 + + local output + output="$(tmux show-environment -g 2>/dev/null)" || return 0 + + local line key filtered="" did_change=0 + while IFS= read -r line; do + [[ "$line" == CMUX_* ]] || continue + key="${line%%=*}" + _cmux_tmux_sync_key_is_managed "$key" || continue + filtered+="${line}"$'\n' + done <<< "$output" + + [[ -n "$filtered" ]] || return 0 + [[ "$filtered" == "$_CMUX_TMUX_PULL_SIGNATURE" ]] && return 0 + + local value + while IFS= read -r line; do + [[ "$line" == CMUX_* ]] || continue + key="${line%%=*}" + _cmux_tmux_sync_key_is_managed "$key" || continue + value="${line#*=}" + if [[ "${(P)key}" != "$value" ]]; then + export "$key=$value" + did_change=1 + fi + done <<< "$filtered" + + _CMUX_TMUX_PULL_SIGNATURE="$filtered" + if (( did_change )); then + _CMUX_TTY_REPORTED=0 + _CMUX_SHELL_ACTIVITY_LAST="" + _CMUX_PWD_LAST_PWD="" + _CMUX_GIT_LAST_PWD="" + _CMUX_GIT_HEAD_LAST_PWD="" + _CMUX_GIT_HEAD_PATH="" + _CMUX_GIT_HEAD_SIGNATURE="" + _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 + _cmux_stop_pr_poll_loop + _cmux_stop_git_head_watch + fi +} + +_cmux_tmux_sync_cmux_environment() { + if [[ -n "$TMUX" ]]; then + _cmux_tmux_refresh_cmux_environment + else + _cmux_tmux_publish_cmux_environment + fi +} _cmux_ensure_ghostty_preexec_strips_both_marks() { local fn_name="$1" @@ -190,17 +313,32 @@ _cmux_git_head_signature() { return 1 } +_cmux_report_tty_payload() { + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$_CMUX_TTY_NAME" ]] || return 0 + + local payload="report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID" + if [[ -z "$TMUX" ]]; then + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + payload+=" --panel=$CMUX_PANEL_ID" + fi + + print -r -- "$payload" +} + _cmux_report_tty_once() { # Send the TTY name to the app once per session so the batched port scanner # knows which TTY belongs to this panel. (( _CMUX_TTY_REPORTED )) && return 0 [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 - [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 - [[ -n "$_CMUX_TTY_NAME" ]] || return 0 + + local payload="" + payload="$(_cmux_report_tty_payload)" + [[ -n "$payload" ]] || return 0 + _CMUX_TTY_REPORTED=1 { - _cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + _cmux_send "$payload" } >/dev/null 2>&1 &! } @@ -506,6 +644,8 @@ _cmux_start_git_head_watch() { } _cmux_preexec() { + _cmux_tmux_sync_cmux_environment + if [[ -z "$_CMUX_TTY_NAME" ]]; then local t t="$(tty 2>/dev/null || true)" @@ -533,6 +673,7 @@ _cmux_preexec() { _cmux_precmd() { _cmux_stop_git_head_watch + _cmux_tmux_sync_cmux_environment # Skip if socket doesn't exist yet [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 15d51723175f..cba1b2148b18 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -929,7 +929,9 @@ final class FileDropOverlayView: NSView { var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 +private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") +let tmuxWorkspacePaneOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.tmuxWorkspacePane.overlay.container") enum CommandPaletteOverlayPromotionPolicy { static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool { @@ -950,6 +952,15 @@ private final class CommandPaletteOverlayContainerView: NSView { } } +@MainActor +private final class PassthroughWindowOverlayContainerView: NSView { + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + @MainActor private final class WindowCommandPaletteOverlayController: NSObject { private weak var window: NSWindow? @@ -1266,6 +1277,103 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind return controller } +@MainActor +private final class WindowTmuxWorkspacePaneOverlayController: NSObject { + private weak var window: NSWindow? + private let containerView = PassthroughWindowOverlayContainerView(frame: .zero) + private let model = TmuxWorkspacePaneOverlayModel() + private let hostingView: NSHostingView + private var installConstraints: [NSLayoutConstraint] = [] + + init(window: NSWindow) { + self.window = window + self.hostingView = NSHostingView( + rootView: TmuxWorkspacePaneOverlayView( + unreadRects: [], + flashRect: nil, + flashStartedAt: nil, + flashReason: nil + ) + ) + super.init() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.isHidden = true + containerView.alphaValue = 0 + containerView.identifier = tmuxWorkspacePaneOverlayContainerIdentifier + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return false } + + if containerView.superview !== themeFrame { + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + containerView.removeFromSuperview() + themeFrame.addSubview(containerView, positioned: .above, relativeTo: contentView) + installConstraints = [ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ] + NSLayoutConstraint.activate(installConstraints) + } + + return true + } + + func update(state: TmuxWorkspacePaneOverlayRenderState?) { + guard ensureInstalled() else { return } + if let state { + model.apply(state) + hostingView.rootView = TmuxWorkspacePaneOverlayView( + unreadRects: model.unreadRects, + flashRect: model.flashRect, + flashStartedAt: model.flashStartedAt, + flashReason: model.flashReason + ) + containerView.alphaValue = 1 + containerView.isHidden = false + } else { + model.clear() + hostingView.rootView = TmuxWorkspacePaneOverlayView( + unreadRects: [], + flashRect: nil, + flashStartedAt: nil, + flashReason: nil + ) + containerView.alphaValue = 0 + containerView.isHidden = true + } + } +} + +@MainActor +private func tmuxWorkspacePaneWindowOverlayController(for window: NSWindow) -> WindowTmuxWorkspacePaneOverlayController { + if let existing = objc_getAssociatedObject(window, &tmuxWorkspacePaneWindowOverlayKey) as? WindowTmuxWorkspacePaneOverlayController { + return existing + } + let controller = WindowTmuxWorkspacePaneOverlayController(window: window) + objc_setAssociatedObject(window, &tmuxWorkspacePaneWindowOverlayKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return controller +} + private func commandPaletteOwningWebView(for responder: NSResponder?) -> WKWebView? { guard let responder else { return nil } @@ -1622,6 +1730,135 @@ struct ContentView: View { var lastUsedAt: TimeInterval } + static func tmuxWorkspacePaneExactRect( + for panel: Panel, + in contentView: NSView + ) -> CGRect? { + let targetView: NSView? + switch panel { + case let terminal as TerminalPanel: + targetView = terminal.hostedView + case let browser as BrowserPanel: + targetView = browser.webView + default: + targetView = nil + } + guard let targetView else { return nil } + return tmuxWorkspacePaneExactRect(for: targetView, in: contentView) + } + + static func tmuxWorkspacePaneExactRect( + for targetView: NSView, + in contentView: NSView + ) -> CGRect? { + guard let contentWindow = contentView.window, + let targetWindow = targetView.window, + contentWindow === targetWindow, + targetView.superview != nil else { + return nil + } + + let rectInWindow = targetView.convert(targetView.bounds, to: nil) + let rectInContent = contentView.convert(rectInWindow, from: nil) + guard rectInContent.width > 1, rectInContent.height > 1 else { return nil } + return rectInContent + } + + static func preferredTmuxWorkspacePaneWindowOverlayRect( + exactRect: CGRect?, + paneRect: CGRect? + ) -> CGRect? { + guard let paneRect else { return exactRect } + guard let exactRect, + exactRect.width > 1, + exactRect.height > 1 else { + return paneRect + } + + let tolerance: CGFloat = 0.5 + let exactFitsWithinPane = + exactRect.minX >= paneRect.minX - tolerance && + exactRect.maxX <= paneRect.maxX + tolerance && + exactRect.minY >= paneRect.minY - tolerance && + exactRect.maxY <= paneRect.maxY + tolerance + return exactFitsWithinPane ? exactRect : paneRect + } + + private func tmuxWorkspacePaneWindowOverlayState(for window: NSWindow) -> TmuxWorkspacePaneOverlayRenderState? { + guard TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay, + let workspace = tabManager.selectedWorkspace else { return nil } + let layoutSnapshot = WorkspaceContentView.effectiveTmuxLayoutSnapshot( + cachedSnapshot: workspace.tmuxLayoutSnapshot, + liveSnapshot: workspace.bonsplitController.layoutSnapshot() + ) + let contentView = window.contentView + + let unreadRects: [CGRect] + if let layoutSnapshot, let contentView { + unreadRects = layoutSnapshot.panes.compactMap { pane in + guard let selectedTabId = pane.selectedTabId, + let tabUUID = UUID(uuidString: selectedTabId), + let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)), + let panel = workspace.panels[panelId] else { + return nil + } + + let shouldShowUnread = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( + forTabId: workspace.id, + surfaceId: panelId + ), + isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId) + ) + guard shouldShowUnread else { return nil } + + let paneRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect( + layoutSnapshot: layoutSnapshot, + paneId: workspace.paneId(forPanelId: panelId) + ) + let exactRect = Self.tmuxWorkspacePaneExactRect(for: panel, in: contentView) + return Self.preferredTmuxWorkspacePaneWindowOverlayRect( + exactRect: exactRect, + paneRect: paneRect + ) + } + } else { + unreadRects = WorkspaceContentView.tmuxWorkspacePaneWindowUnreadRects( + workspace: workspace, + notificationStore: notificationStore, + layoutSnapshot: layoutSnapshot + ) + } + + let flashRect: CGRect? + if let panelId = workspace.tmuxWorkspaceFlashPanelId, + let panel = workspace.panels[panelId], + let contentView { + let paneRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect( + layoutSnapshot: layoutSnapshot, + paneId: workspace.paneId(forPanelId: panelId) + ) + let exactRect = Self.tmuxWorkspacePaneExactRect(for: panel, in: contentView) + flashRect = Self.preferredTmuxWorkspacePaneWindowOverlayRect( + exactRect: exactRect, + paneRect: paneRect + ) + } else { + flashRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect( + layoutSnapshot: layoutSnapshot, + paneId: workspace.tmuxWorkspaceFlashPanelId.flatMap { workspace.paneId(forPanelId: $0) } + ) + } + + return TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspace.id, + unreadRects: unreadRects, + flashRect: flashRect, + flashToken: workspace.tmuxWorkspaceFlashToken, + flashReason: workspace.tmuxWorkspaceFlashReason + ) + } + private struct CommandPaletteContextSnapshot { private var boolValues: [String: Bool] = [:] private var stringValues: [String: String] = [:] @@ -2762,6 +2999,8 @@ struct ContentView: View { view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in MainActor.assumeIsolated { + let tmuxOverlayController = tmuxWorkspacePaneWindowOverlayController(for: window) + tmuxOverlayController.update(state: tmuxWorkspacePaneWindowOverlayState(for: window)) let overlayController = commandPaletteWindowOverlayController(for: window) overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4302bd2135ff..5bd27e93eddf 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6470,13 +6470,32 @@ func shouldAllowEnsureFocusWindowActivation( final class GhosttySurfaceScrollView: NSView { enum FlashStyle { - case standardFocus - case notificationDismiss + case navigation + case notification + } + + static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> FlashStyle { + switch reason { + case .navigation: + return .navigation + case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: + return .notification + } + } + + private static func flashPresentation(for style: FlashStyle) -> WorkspaceAttentionFlashPresentation { + switch style { + case .navigation: + return WorkspaceAttentionCoordinator.flashStyle(for: .navigation) + case .notification: + return WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival) + } } private enum NotificationRingMetrics { - static let inset: CGFloat = 2 - static let cornerRadius: CGFloat = 6 + static let inset = PanelOverlayRingMetrics.inset + static let cornerRadius = PanelOverlayRingMetrics.cornerRadius + static let lineWidth = PanelOverlayRingMetrics.lineWidth } private let backgroundView: NSView @@ -6489,6 +6508,7 @@ final class GhosttySurfaceScrollView: NSView { private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer + private var lastFlashStyle: FlashStyle = .navigation private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeIconView: NSImageView @@ -6743,7 +6763,7 @@ final class GhosttySurfaceScrollView: NSView { notificationRingOverlayView.autoresizingMask = [.width, .height] notificationRingLayer.fillColor = NSColor.clear.cgColor notificationRingLayer.strokeColor = NSColor.systemBlue.cgColor - notificationRingLayer.lineWidth = 2.5 + notificationRingLayer.lineWidth = NotificationRingMetrics.lineWidth notificationRingLayer.lineJoin = .round notificationRingLayer.lineCap = .round notificationRingLayer.shadowColor = NSColor.systemBlue.cgColor @@ -6759,13 +6779,13 @@ final class GhosttySurfaceScrollView: NSView { flashOverlayView.layer?.masksToBounds = false flashOverlayView.autoresizingMask = [.width, .height] flashLayer.fillColor = NSColor.clear.cgColor - flashLayer.strokeColor = NSColor.systemBlue.cgColor - flashLayer.lineWidth = 3 + flashLayer.strokeColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor + flashLayer.lineWidth = NotificationRingMetrics.lineWidth flashLayer.lineJoin = .round flashLayer.lineCap = .round - flashLayer.shadowColor = NSColor.systemBlue.cgColor - flashLayer.shadowOpacity = 0.6 - flashLayer.shadowRadius = 6 + flashLayer.shadowColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor + flashLayer.shadowOpacity = Float(WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowOpacity) + flashLayer.shadowRadius = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowRadius flashLayer.shadowOffset = .zero flashLayer.opacity = 0 flashOverlayView.layer?.addSublayer(flashLayer) @@ -7069,7 +7089,8 @@ final class GhosttySurfaceScrollView: NSView { // which makes interactive width changes arrive a queue turn late on Sequoia. scrollView.layoutSubtreeIfNeeded() updateNotificationRingPath() - updateFlashPath(style: .standardFocus) + updateFlashPath(style: lastFlashStyle) + updateFlashAppearance(style: lastFlashStyle) synchronizeScrollView() synchronizeSurfaceView() let didCoreSurfaceChange = synchronizeCoreSurface() @@ -7801,15 +7822,17 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func triggerFlash(style: FlashStyle = .standardFocus) { + func triggerFlash(style: FlashStyle = .navigation) { DispatchQueue.main.async { [weak self] in guard let self else { return } -#if DEBUG + self.lastFlashStyle = style + #if DEBUG if let surfaceId = self.surfaceView.terminalSurface?.id { Self.recordFlash(for: surfaceId) } #endif self.updateFlashPath(style: style) + self.updateFlashAppearance(style: style) self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") @@ -8873,10 +8896,7 @@ final class GhosttySurfaceScrollView: NSView { let inset: CGFloat let radius: CGFloat switch style { - case .standardFocus: - inset = CGFloat(FocusFlashPattern.ringInset) - radius = CGFloat(FocusFlashPattern.ringCornerRadius) - case .notificationDismiss: + case .navigation, .notification: inset = NotificationRingMetrics.inset radius = NotificationRingMetrics.cornerRadius } @@ -8888,6 +8908,15 @@ final class GhosttySurfaceScrollView: NSView { ) } + private func updateFlashAppearance(style: FlashStyle) { + let presentation = Self.flashPresentation(for: style) + let strokeColor = presentation.accent.strokeColor + flashLayer.strokeColor = strokeColor.cgColor + flashLayer.shadowColor = strokeColor.cgColor + flashLayer.shadowOpacity = Float(presentation.glowOpacity) + flashLayer.shadowRadius = presentation.glowRadius + } + private func updateOverlayRingPath( layer: CAShapeLayer, bounds: CGRect, @@ -8899,7 +8928,7 @@ final class GhosttySurfaceScrollView: NSView { layer.path = nil return } - let rect = bounds.insetBy(dx: inset, dy: inset) + let rect = PanelOverlayRingMetrics.pathRect(in: bounds) layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index da61b37e0138..4049c30e2882 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2858,7 +2858,8 @@ final class BrowserPanel: Panel, ObservableObject { return true } - func triggerFlash() { + func triggerFlash(reason: WorkspaceAttentionFlashReason) { + _ = reason guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken &+= 1 } diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 2e74944d57bc..9608df6c8e72 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -75,7 +75,8 @@ final class MarkdownPanel: Panel, ObservableObject { stopFileWatcher() } - func triggerFlash() { + func triggerFlash(reason: WorkspaceAttentionFlashReason) { + _ = reason guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken += 1 } diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index bcbf5b7d3c19..fc0554dd9428 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -26,11 +26,134 @@ public enum PanelFocusIntent: Equatable { case browser(BrowserPanelFocusIntent) } +public enum WorkspaceAttentionFlashReason: String, Equatable, Sendable { + case navigation + case notificationArrival + case notificationDismiss + case manualUnreadDismiss + case debug +} + +enum WorkspaceAttentionFlashAccent: Equatable, Sendable { + case notificationBlue + case navigationTeal + + var strokeColor: NSColor { + switch self { + case .notificationBlue: + return .systemBlue + case .navigationTeal: + return .systemTeal + } + } +} + +struct WorkspaceAttentionFlashPresentation: Equatable, Sendable { + let accent: WorkspaceAttentionFlashAccent + let glowOpacity: Double + let glowRadius: CGFloat +} + +struct WorkspaceAttentionPersistentState: Equatable, Sendable { + var unreadPanelIDs: Set = [] + var focusedReadPanelID: UUID? + var manualUnreadPanelIDs: Set = [] + + var indicatorPanelIDs: Set { + var ids = unreadPanelIDs.union(manualUnreadPanelIDs) + if let focusedReadPanelID { + ids.insert(focusedReadPanelID) + } + return ids + } + + func hasCompetingIndicator(for panelID: UUID) -> Bool { + indicatorPanelIDs.contains(where: { $0 != panelID }) + } +} + +struct WorkspaceAttentionFlashDecision: Equatable, Sendable { + let panelID: UUID + let reason: WorkspaceAttentionFlashReason + let isAllowed: Bool +} + +enum WorkspaceAttentionCoordinator { + static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> WorkspaceAttentionFlashPresentation { + switch reason { + case .navigation: + return WorkspaceAttentionFlashPresentation( + accent: .navigationTeal, + glowOpacity: 0.14, + glowRadius: 3 + ) + case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: + return WorkspaceAttentionFlashPresentation( + accent: .notificationBlue, + glowOpacity: 0.6, + glowRadius: 6 + ) + } + } + + static func decideFlash( + targetPanelID: UUID, + reason: WorkspaceAttentionFlashReason, + persistentState: WorkspaceAttentionPersistentState + ) -> WorkspaceAttentionFlashDecision { + let isAllowed: Bool + switch reason { + case .navigation: + isAllowed = !persistentState.hasCompetingIndicator(for: targetPanelID) + case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: + isAllowed = true + } + + return WorkspaceAttentionFlashDecision( + panelID: targetPanelID, + reason: reason, + isAllowed: isAllowed + ) + } +} + enum FocusFlashCurve: Equatable { case easeIn case easeOut } +enum PanelOverlayRingMetrics { + static let inset: CGFloat = 2 + static let cornerRadius: CGFloat = 6 + static let lineWidth: CGFloat = 2.5 + + static func pathRect(in bounds: CGRect) -> CGRect { + bounds.insetBy(dx: inset, dy: inset) + } +} + +#if DEBUG +func cmuxFlashDebugID(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(6)) +} + +func cmuxFlashDebugRect(_ rect: CGRect?) -> String { + guard let rect else { return "nil" } + return String( + format: "%.1f,%.1f %.1fx%.1f", + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height + ) +} + +func cmuxFlashDebugBool(_ value: Bool) -> Int { + value ? 1 : 0 +} +#endif + struct FocusFlashSegment: Equatable { let delay: TimeInterval let duration: TimeInterval @@ -43,8 +166,8 @@ enum FocusFlashPattern { static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1] static let duration: TimeInterval = 0.9 static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn] - static let ringInset: Double = 6 - static let ringCornerRadius: Double = 10 + static let ringInset: Double = Double(PanelOverlayRingMetrics.inset) + static let ringCornerRadius: Double = Double(PanelOverlayRingMetrics.cornerRadius) static var segments: [FocusFlashSegment] { let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) @@ -59,6 +182,37 @@ enum FocusFlashPattern { ) } } + + static func opacity(at elapsed: TimeInterval) -> Double { + guard elapsed >= 0, elapsed <= duration else { return 0 } + + for index in 0.. endTime { + continue + } + + let segmentDuration = max(endTime - startTime, 0.0001) + let rawProgress = max(0, min(1, (elapsed - startTime) / segmentDuration)) + let curvedProgress = interpolatedProgress(rawProgress, curve: curves[index]) + let startOpacity = values[index] + let endOpacity = values[index + 1] + return startOpacity + ((endOpacity - startOpacity) * curvedProgress) + } + + return values.last ?? 0 + } + + private static func interpolatedProgress(_ progress: Double, curve: FocusFlashCurve) -> Double { + switch curve { + case .easeIn: + return progress * progress + case .easeOut: + let inverse = 1 - progress + return 1 - (inverse * inverse) + } + } } /// Protocol for all panel types (terminal, browser, etc.) @@ -89,7 +243,7 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI func unfocus() /// Trigger a focus flash animation for this panel. - func triggerFlash() + func triggerFlash(reason: WorkspaceAttentionFlashReason) /// Capture the panel-local focus target that should be restored later. func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent @@ -149,4 +303,8 @@ extension Panel { _ = window return false } + + func triggerFlash() { + triggerFlash(reason: .navigation) + } } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 3df74b9c4bb7..7bc65d51a1b6 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -22,6 +22,8 @@ final class TerminalPanel: Panel, ObservableObject { /// Published directory from the terminal @Published private(set) var directory: String = "" + @Published private(set) var tmuxLayoutReport: TmuxPaneLayoutReport? + /// Search state for find functionality @Published var searchState: TerminalSurface.SearchState? { didSet { @@ -36,6 +38,8 @@ final class TerminalPanel: Panel, ObservableObject { /// (hostedView.window == nil) until the user switches workspaces. @Published var viewReattachToken: UInt64 = 0 + var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)? + private var cancellables = Set() var displayTitle: String { @@ -125,6 +129,11 @@ final class TerminalPanel: Panel, ObservableObject { surface.updateWorkspaceId(newWorkspaceId) } + func updateTmuxLayoutReport(_ report: TmuxPaneLayoutReport?) { + guard tmuxLayoutReport != report else { return } + tmuxLayoutReport = report + } + func focus() { surface.setFocus(true) // `unfocus()` force-disables active state to stop stale retries from stealing focus. @@ -200,14 +209,23 @@ final class TerminalPanel: Panel, ObservableObject { !surface.needsConfirmClose() } - func triggerFlash() { + func triggerFlash(reason: WorkspaceAttentionFlashReason) { guard NotificationPaneFlashSettings.isEnabled() else { return } - hostedView.triggerFlash() + + switch TmuxOverlayExperimentSettings.target() { + case .bonsplitPane: + if let onRequestWorkspacePaneFlash { + onRequestWorkspacePaneFlash(reason) + return + } + hostedView.triggerFlash(style: GhosttySurfaceScrollView.flashStyle(for: reason)) + case .surface, .tmuxActivePane: + hostedView.triggerFlash(style: GhosttySurfaceScrollView.flashStyle(for: reason)) + } } func triggerNotificationDismissFlash() { - guard NotificationPaneFlashSettings.isEnabled() else { return } - hostedView.triggerFlash(style: .notificationDismiss) + triggerFlash(reason: .notificationDismiss) } func applyWindowBackgroundIfActive() { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6df6abaf3ab2..3375950ae876 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -756,7 +756,7 @@ class TabManager: ObservableObject { self.focusSelectedTabPanel(previousTabId: previousTabId) self.updateWindowTitleForSelectedTab() if let selectedTabId = self.selectedTabId { - self.markFocusedPanelReadIfActive(tabId: selectedTabId) + self.dismissFocusedPanelNotificationIfActive(tabId: selectedTabId) } #if DEBUG let dtMs = self.debugWorkspaceSwitchStartTime > 0 @@ -849,7 +849,7 @@ class TabManager: ObservableObject { guard let self else { return } guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId) + dismissPanelNotificationOnFocusIfActive(tabId: tabId, panelId: surfaceId) } }) @@ -2742,25 +2742,20 @@ class TabManager: ObservableObject { selectedTabId != pendingTabId } - private func markFocusedPanelReadIfActive(tabId: UUID) { + private func dismissFocusedPanelNotificationIfActive(tabId: UUID) { let shouldSuppressFlash = suppressFocusFlash suppressFocusFlash = false guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) + dismissPanelNotificationOnFocusIfActive(tabId: tabId, panelId: panelId) } - private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { + private func dismissPanelNotificationOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } guard AppFocusState.isAppActive() else { return } - guard let notificationStore = AppDelegate.shared?.notificationStore else { return } - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return } - if let tab = tabs.first(where: { $0.id == tabId }) { - tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) - } - notificationStore.markRead(forTabId: tabId, surfaceId: panelId) + _ = dismissNotificationOnDirectInteraction(tabId: tabId, surfaceId: panelId) } @discardableResult @@ -2768,12 +2763,17 @@ class TabManager: ObservableObject { guard selectedTabId == tabId else { return false } guard AppFocusState.isAppActive() else { return false } guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } + let hasUnreadNotification = notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) + let hasFocusedIndicator = notificationStore.hasVisibleNotificationIndicator(forTabId: tabId, surfaceId: surfaceId) + guard hasUnreadNotification || hasFocusedIndicator else { return false } + if hasUnreadNotification { + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + } + notificationStore.clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) if let panelId = surfaceId, let tab = tabs.first(where: { $0.id == tabId }) { - tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) + tab.triggerNotificationDismissFlash(panelId: panelId) } - notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) return true } @@ -2882,7 +2882,6 @@ class TabManager: ObservableObject { @discardableResult func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) -> Bool { - let wasSelected = selectedTabId == tabId guard let tab = tabs.first(where: { $0.id == tabId }) else { #if DEBUG dlog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab") @@ -2909,20 +2908,11 @@ class TabManager: ObservableObject { tab.clearSplitZoom() suppressFocusFlash = true focusTab(tabId, surfaceId: desiredPanelId, suppressFlash: true) - if wasSelected { - suppressFocusFlash = false - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - guard let self, - let tab = self.tabs.first(where: { $0.id == tabId }) else { return } - let targetPanelId = desiredPanelId ?? tab.focusedPanelId - guard let targetPanelId, - tab.panels[targetPanelId] != nil else { return } - guard let notificationStore = AppDelegate.shared?.notificationStore else { return } - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetPanelId) else { return } - tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true) - notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId) + suppressFocusFlash = false + + if let targetPanelId = desiredPanelId ?? tab.focusedPanelId, + tab.panels[targetPanelId] != nil { + _ = dismissNotificationOnDirectInteraction(tabId: tabId, surfaceId: targetPanelId) } return true } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 84b7a5a1c649..d75ca8ed410a 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -697,6 +697,7 @@ final class TerminalNotificationStore: ObservableObject { refreshDockBadge() } } + @Published private(set) var focusedReadIndicatorByTabId: [UUID: UUID] = [:] @Published private(set) var authorizationState: NotificationAuthorizationState = .unknown private let center = UNUserNotificationCenter.current() @@ -876,10 +877,19 @@ final class TerminalNotificationStore: ObservableObject { indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)) } + func hasVisibleNotificationIndicator(forTabId tabId: UUID, surfaceId: UUID?) -> Bool { + hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) || + focusedReadIndicatorByTabId[tabId] == surfaceId + } + func latestNotification(forTabId tabId: UUID) -> TerminalNotification? { indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId] } + func focusedReadIndicatorSurfaceId(forTabId tabId: UUID) -> UUID? { + focusedReadIndicatorByTabId[tabId] + } + func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) { var updated = notifications var idsToClear: [String] = [] @@ -889,12 +899,20 @@ final class TerminalNotificationStore: ObservableObject { return true } + if let existingIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId], + existingIndicatorSurfaceId != surfaceId { + focusedReadIndicatorByTabId.removeValue(forKey: tabId) + } + let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId) let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId let isFocusedPanel = isActiveTab && isFocusedSurface let isAppFocused = AppFocusState.isAppFocused() let shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel + if shouldSuppressExternalDelivery { + setFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) + } if WorkspaceAutoReorderSettings.isEnabled() { AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId) @@ -979,6 +997,24 @@ final class TerminalNotificationStore: ObservableObject { } } + func setFocusedReadIndicator(forTabId tabId: UUID, surfaceId: UUID?) { + guard let surfaceId else { return } + guard focusedReadIndicatorByTabId[tabId] != surfaceId else { return } + focusedReadIndicatorByTabId[tabId] = surfaceId + } + + func clearFocusedReadIndicator(forTabId tabId: UUID, surfaceId: UUID? = nil) { + guard let existingSurfaceId = focusedReadIndicatorByTabId[tabId] else { return } + guard surfaceId == nil || existingSurfaceId == surfaceId else { return } + focusedReadIndicatorByTabId.removeValue(forKey: tabId) + } + + func clearFocusedReadIndicatorIfSurfaceChanged(forTabId tabId: UUID, surfaceId: UUID?) { + guard let existingSurfaceId = focusedReadIndicatorByTabId[tabId] else { return } + guard existingSurfaceId != surfaceId else { return } + focusedReadIndicatorByTabId.removeValue(forKey: tabId) + } + func markAllRead() { var updated = notifications var idsToClear: [String] = [] @@ -997,17 +1033,22 @@ final class TerminalNotificationStore: ObservableObject { func remove(id: UUID) { var updated = notifications + let removed = updated.first(where: { $0.id == id }) let originalCount = updated.count updated.removeAll { $0.id == id } guard updated.count != originalCount else { return } notifications = updated + if let removed { + clearFocusedReadIndicator(forTabId: removed.tabId, surfaceId: removed.surfaceId) + } center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) } func clearAll() { - guard !notifications.isEmpty else { return } + guard !notifications.isEmpty || !focusedReadIndicatorByTabId.isEmpty else { return } let ids = notifications.map { $0.id.uuidString } notifications.removeAll() + focusedReadIndicatorByTabId.removeAll() center.removeDeliveredNotificationsOffMain(withIdentifiers: ids) center.removePendingNotificationRequestsOffMain(withIdentifiers: ids) } @@ -1025,6 +1066,7 @@ final class TerminalNotificationStore: ObservableObject { } guard !idsToClear.isEmpty else { return } notifications = updated + clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } @@ -1042,6 +1084,7 @@ final class TerminalNotificationStore: ObservableObject { } guard !idsToClear.isEmpty else { return } notifications = updated + clearFocusedReadIndicator(forTabId: tabId) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } @@ -1334,6 +1377,7 @@ final class TerminalNotificationStore: ObservableObject { func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) { self.notifications = notifications + focusedReadIndicatorByTabId.removeAll() } #endif diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index da7df21c21bb..565a842737f2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5201,6 +5201,10 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set = [] @Published private(set) var manualUnreadPanelIds: Set = [] + @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? + @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? + @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? + @Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0 private var manualUnreadMarkedAt: [UUID: Date] = [:] nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @@ -5437,6 +5441,7 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: initialTerminalCommand, initialEnvironmentOverrides: initialTerminalEnvironment ) + configureTerminalPanel(terminalPanel) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) @@ -5487,6 +5492,7 @@ final class Workspace: Identifiable, ObservableObject { } bonsplitController.selectTab(initialTabId) } + tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } deinit { @@ -5607,6 +5613,19 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId.first { $0.value == panelId }?.key } + private func configureTerminalPanel(_ terminalPanel: TerminalPanel) { + terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in + guard let self, let terminalPanel else { return } + self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) + } + } + + private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + tmuxWorkspaceFlashPanelId = panelId + tmuxWorkspaceFlashReason = reason + tmuxWorkspaceFlashToken &+= 1 + } + private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { let subscription = Publishers.CombineLatest3( @@ -5768,7 +5787,31 @@ final class Workspace: Identifiable, ObservableObject { } private func hasUnreadNotification(panelId: UUID) -> Bool { - AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false + AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false + } + + private func attentionPersistentState() -> WorkspaceAttentionPersistentState { + let notificationStore = AppDelegate.shared?.notificationStore + let unreadPanelIDs = Set( + panels.keys.filter { + notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false + } + ) + return WorkspaceAttentionPersistentState( + unreadPanelIDs: unreadPanelIDs, + focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), + manualUnreadPanelIDs: manualUnreadPanelIds + ) + } + + private func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + let decision = WorkspaceAttentionCoordinator.decideFlash( + targetPanelID: panelId, + reason: reason, + persistentState: attentionPersistentState() + ) + guard decision.isAllowed else { return } + panels[panelId]?.triggerFlash(reason: reason) } private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { @@ -6983,6 +7026,7 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal, initialCommand: remoteTerminalStartupCommand ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle if remoteTerminalStartupCommand != nil { @@ -7072,6 +7116,7 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: remoteTerminalStartupCommand, additionalEnvironment: startupEnvironment ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle if remoteTerminalStartupCommand != nil { @@ -8184,8 +8229,10 @@ final class Workspace: Identifiable, ObservableObject { } func moveFocus(direction: NavigationDirection) { + let previousFocusedPanelId = focusedPanelId + // Unfocus the currently-focused panel before navigating. - if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] { + if let prevPanelId = previousFocusedPanelId, let prev = panels[prevPanelId] { prev.unfocus() } @@ -8197,6 +8244,7 @@ final class Workspace: Identifiable, ObservableObject { let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { applyTabSelection(tabId: tabId, inPane: paneId) } + } // MARK: - Surface Navigation @@ -8301,7 +8349,7 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Flash/Notification Support func triggerFocusFlash(panelId: UUID) { - panels[panelId]?.triggerFlash() + requestAttentionFlash(panelId: panelId, reason: .navigation) } func triggerNotificationFocusFlash( @@ -8309,7 +8357,7 @@ final class Workspace: Identifiable, ObservableObject { requiresSplit: Bool = false, shouldFocus: Bool = true ) { - guard let terminalPanel = terminalPanel(for: panelId) else { return } + guard terminalPanel(for: panelId) != nil else { return } if shouldFocus { focusPanel(panelId) } @@ -8317,11 +8365,18 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerFlash() + requestAttentionFlash(panelId: panelId, reason: .notificationArrival) + } + + func triggerNotificationDismissFlash(panelId: UUID) { + guard terminalPanel(for: panelId) != nil else { return } + requestAttentionFlash(panelId: panelId, reason: .notificationDismiss) } func triggerDebugFlash(panelId: UUID) { - triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true) + guard panels[panelId] != nil else { return } + focusPanel(panelId) + requestAttentionFlash(panelId: panelId, reason: .debug) } // MARK: - Portal Lifecycle @@ -8359,6 +8414,7 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -10040,6 +10096,7 @@ extension Workspace: BonsplitDelegate { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(replacementPanel) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) @@ -10106,6 +10163,7 @@ extension Workspace: BonsplitDelegate { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -10200,7 +10258,7 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { - _ = snapshot + tmuxLayoutSnapshot = snapshot scheduleTerminalGeometryReconcile() if !isDetachingCloseTransaction { scheduleFocusReconcile() diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index a74efffaff00..b5c66519b31b 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -3,6 +3,217 @@ import Foundation import AppKit import Bonsplit +enum TmuxOverlayExperimentTarget: String, CaseIterable, Codable, Sendable { + case surface + case bonsplitPane + case tmuxActivePane + + var usesWorkspacePaneOverlay: Bool { + self == .bonsplitPane + } + + var usesTmuxActivePaneOverlay: Bool { + self == .tmuxActivePane + } +} + +struct TmuxOverlayExperimentSettings { + static let enabledKey = "tmuxOverlayExperimentEnabled" + static let targetKey = "tmuxOverlayExperimentTarget" + static let defaultEnabled = false + static let defaultTarget: TmuxOverlayExperimentTarget = .surface + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled + } + + static func target(defaults: UserDefaults = .standard) -> TmuxOverlayExperimentTarget { + target( + enabled: isEnabled(defaults: defaults), + rawValue: defaults.string(forKey: targetKey) + ) + } + + static func target(enabled: Bool, rawValue: String?) -> TmuxOverlayExperimentTarget { + guard enabled else { return .surface } + guard let rawValue, + let target = TmuxOverlayExperimentTarget(rawValue: rawValue) else { + return defaultTarget + } + return target + } +} + +struct TmuxPaneLayoutPane: Codable, Equatable, Sendable { + let paneId: String + let left: Int + let top: Int + let width: Int + let height: Int + let isActive: Bool +} + +struct TmuxPaneLayoutReport: Codable, Equatable, Sendable { + let panes: [TmuxPaneLayoutPane] + + var activePane: TmuxPaneLayoutPane? { + panes.first(where: \.isActive) ?? panes.first + } +} + +func tmuxActivePaneOverlayRect( + surfaceFrame: CGRect, + cellSize: CGSize, + pane: TmuxPaneLayoutPane +) -> CGRect? { + guard cellSize.width > 0, + cellSize.height > 0, + pane.width > 0, + pane.height > 0 else { + return nil + } + + return CGRect( + x: surfaceFrame.origin.x + (CGFloat(pane.left) * cellSize.width), + y: surfaceFrame.origin.y + (CGFloat(pane.top) * cellSize.height), + width: CGFloat(pane.width) * cellSize.width, + height: CGFloat(pane.height) * cellSize.height + ) +} + +private extension PixelRect { + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +struct TmuxWorkspacePaneOverlayRenderState: Equatable { + let workspaceId: UUID + let unreadRects: [CGRect] + let flashRect: CGRect? + let flashToken: UInt64 + let flashReason: WorkspaceAttentionFlashReason? +} + +@MainActor +final class TmuxWorkspacePaneOverlayModel: ObservableObject { + @Published private(set) var unreadRects: [CGRect] = [] + @Published private(set) var flashRect: CGRect? + @Published private(set) var flashStartedAt: Date? + @Published private(set) var flashReason: WorkspaceAttentionFlashReason? + + private var lastWorkspaceId: UUID? + private var lastFlashToken: UInt64? + + func apply( + _ state: TmuxWorkspacePaneOverlayRenderState, + now: () -> Date = Date.init + ) { + unreadRects = state.unreadRects + flashRect = state.flashRect + flashReason = state.flashReason + + let didChangeWorkspace = lastWorkspaceId != state.workspaceId + if didChangeWorkspace { + lastWorkspaceId = state.workspaceId + lastFlashToken = state.flashToken + flashStartedAt = nil + return + } + + if let lastFlashToken, + state.flashToken != lastFlashToken, + state.flashRect != nil { + flashStartedAt = now() + } + self.lastFlashToken = state.flashToken + } + + func clear() { + unreadRects = [] + flashRect = nil + flashStartedAt = nil + flashReason = nil + lastWorkspaceId = nil + lastFlashToken = nil + } +} + +struct TmuxWorkspacePaneOverlayView: View { + let unreadRects: [CGRect] + let flashRect: CGRect? + let flashStartedAt: Date? + let flashReason: WorkspaceAttentionFlashReason? + + var body: some View { + TimelineView(.animation) { timeline in + Canvas { context, _ in + for rect in unreadRects { + drawUnreadRing(in: &context, rect: rect) + } + + guard let flashRect, + let flashStartedAt else { return } + let elapsed = timeline.date.timeIntervalSince(flashStartedAt) + let opacity = FocusFlashPattern.opacity(at: elapsed) + guard opacity > 0.001 else { return } + drawFlashRing( + in: &context, + rect: flashRect, + opacity: opacity, + reason: flashReason ?? .notificationArrival + ) + } + } + .allowsHitTesting(false) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect) { + guard let path = ringPath(for: rect) else { return } + var glowContext = context + glowContext.addFilter(.shadow(color: Color.blue.opacity(0.35), radius: 3)) + glowContext.stroke( + path, + with: .color(Color.blue), + style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round) + ) + } + + private func drawFlashRing( + in context: inout GraphicsContext, + rect: CGRect, + opacity: Double, + reason: WorkspaceAttentionFlashReason + ) { + guard let path = ringPath(for: rect) else { return } + let presentation = WorkspaceAttentionCoordinator.flashStyle(for: reason) + let strokeColor = Color(nsColor: presentation.accent.strokeColor) + + var glowContext = context + glowContext.addFilter( + .shadow( + color: strokeColor.opacity(opacity * presentation.glowOpacity), + radius: presentation.glowRadius + ) + ) + glowContext.stroke( + path, + with: .color(strokeColor.opacity(opacity)), + style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round) + ) + } + + private func ringPath(for rect: CGRect) -> Path? { + guard rect.width > PanelOverlayRingMetrics.inset * 2, + rect.height > PanelOverlayRingMetrics.inset * 2 else { return nil } + return Path( + roundedRect: PanelOverlayRingMetrics.pathRect(in: rect), + cornerRadius: PanelOverlayRingMetrics.cornerRadius + ) + } +} + /// View that renders a Workspace's content using BonsplitView struct WorkspaceContentView: View { @ObservedObject var workspace: Workspace @@ -40,6 +251,7 @@ struct WorkspaceContentView: View { let appearance = PanelAppearance.fromConfig(config) let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || workspace.panels.count > 1 + let usesWorkspacePaneOverlay = TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay // Inactive workspaces are kept alive in a ZStack (for state preservation) but their // AppKit-backed views can still intercept drags. Disable drop acceptance for them. @@ -69,8 +281,11 @@ struct WorkspaceContentView: View { isSelectedInPane: isSelectedInPane, isFocused: isFocused ) - let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), + let showsNotificationRing = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( + forTabId: workspace.id, + surfaceId: panel.id + ), isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) ) PanelContentView( @@ -82,7 +297,7 @@ struct WorkspaceContentView: View { portalPriority: workspacePortalPriority, isSplit: isSplit, appearance: appearance, - hasUnreadNotification: hasUnreadNotification, + hasUnreadNotification: showsNotificationRing && !usesWorkspacePaneOverlay, onFocus: { // Keep bonsplit focus in sync with the AppKit first responder for the // active workspace. This prevents divergence between the blue focused-tab @@ -165,11 +380,6 @@ struct WorkspaceContentView: View { } private func syncBonsplitNotificationBadges() { - let unreadFromNotifications: Set = Set( - notificationStore.notifications - .filter { $0.tabId == workspace.id && !$0.isRead } - .compactMap { $0.surfaceId } - ) let manualUnread = workspace.manualUnreadPanelIds for paneId in workspace.bonsplitController.allPaneIds { @@ -177,7 +387,10 @@ struct WorkspaceContentView: View { let panelId = workspace.panelIdFromSurfaceId(tab.id) let expectedKind = panelId.flatMap { workspace.panelKind(panelId: $0) } let expectedPinned = panelId.map { workspace.isPanelPinned($0) } ?? false - let shouldShow = panelId.map { unreadFromNotifications.contains($0) || manualUnread.contains($0) } ?? false + let shouldShow = panelId.map { + notificationStore.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: $0) || + manualUnread.contains($0) + } ?? false let kindUpdate: String?? = expectedKind.map { .some($0) } if tab.showsNotificationBadge != shouldShow || @@ -198,6 +411,176 @@ struct WorkspaceContentView: View { workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed" } + private static let tmuxWorkspacePaneTopChromeHeight: CGFloat = 30 + + private enum TmuxWorkspacePaneOverlayTrimMode { + case workspaceLocal + case windowContent + } + + private static func tmuxWorkspacePaneContentRect( + _ rect: CGRect, + trimMode: TmuxWorkspacePaneOverlayTrimMode + ) -> CGRect { + let topInset = min(tmuxWorkspacePaneTopChromeHeight, max(0, rect.height - 1)) + switch trimMode { + case .workspaceLocal, .windowContent: + return CGRect( + x: rect.origin.x, + y: rect.origin.y + topInset, + width: rect.width, + height: max(0, rect.height - topInset) + ) + } + } + + private static func tmuxWorkspacePaneRect( + layoutSnapshot: LayoutSnapshot?, + paneId: PaneID?, + includeContainerOffset: Bool, + trimMode: TmuxWorkspacePaneOverlayTrimMode + ) -> CGRect? { + guard let layoutSnapshot, + let paneId, + let paneRect = layoutSnapshot.panes + .first(where: { $0.paneId == paneId.id.uuidString })? + .frame + .cgRect else { + return nil + } + + let rect: CGRect + if includeContainerOffset { + rect = paneRect.offsetBy( + dx: 0, + dy: -CGFloat(layoutSnapshot.containerFrame.y) + ) + } else { + rect = paneRect.offsetBy( + dx: -CGFloat(layoutSnapshot.containerFrame.x), + dy: -CGFloat(layoutSnapshot.containerFrame.y) + ) + } + return tmuxWorkspacePaneContentRect(rect, trimMode: trimMode) + } + + private static func tmuxWorkspacePaneRects( + workspace: Workspace, + notificationStore: TerminalNotificationStore, + layoutSnapshot: LayoutSnapshot?, + includeContainerOffset: Bool, + trimMode: TmuxWorkspacePaneOverlayTrimMode + ) -> [CGRect] { + guard let layoutSnapshot else { return [] } + + return layoutSnapshot.panes.compactMap { pane in + guard let selectedTabId = pane.selectedTabId, + let tabUUID = UUID(uuidString: selectedTabId), + let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)) else { + return nil + } + + let shouldShowUnread = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( + forTabId: workspace.id, + surfaceId: panelId + ), + isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId) + ) + guard shouldShowUnread else { return nil } + + let paneRect = pane.frame.cgRect + let rect: CGRect + if includeContainerOffset { + rect = paneRect.offsetBy( + dx: 0, + dy: -CGFloat(layoutSnapshot.containerFrame.y) + ) + } else { + rect = paneRect.offsetBy( + dx: -CGFloat(layoutSnapshot.containerFrame.x), + dy: -CGFloat(layoutSnapshot.containerFrame.y) + ) + } + return tmuxWorkspacePaneContentRect(rect, trimMode: trimMode) + } + } + + static func tmuxWorkspacePaneOverlayRect( + layoutSnapshot: LayoutSnapshot?, + paneId: PaneID? + ) -> CGRect? { + tmuxWorkspacePaneRect( + layoutSnapshot: layoutSnapshot, + paneId: paneId, + includeContainerOffset: false, + trimMode: .workspaceLocal + ) + } + + static func tmuxWorkspacePaneWindowOverlayRect( + layoutSnapshot: LayoutSnapshot?, + paneId: PaneID? + ) -> CGRect? { + tmuxWorkspacePaneRect( + layoutSnapshot: layoutSnapshot, + paneId: paneId, + includeContainerOffset: true, + trimMode: .windowContent + ) + } + + static func effectiveTmuxLayoutSnapshot( + cachedSnapshot: LayoutSnapshot?, + liveSnapshot: LayoutSnapshot? + ) -> LayoutSnapshot? { + if let liveSnapshot, + tmuxLayoutSnapshotHasRenderableGeometry(liveSnapshot) { + return liveSnapshot + } + if let cachedSnapshot, + tmuxLayoutSnapshotHasRenderableGeometry(cachedSnapshot) { + return cachedSnapshot + } + return cachedSnapshot ?? liveSnapshot + } + + static func tmuxWorkspacePaneUnreadRects( + workspace: Workspace, + notificationStore: TerminalNotificationStore, + layoutSnapshot: LayoutSnapshot? + ) -> [CGRect] { + tmuxWorkspacePaneRects( + workspace: workspace, + notificationStore: notificationStore, + layoutSnapshot: layoutSnapshot, + includeContainerOffset: false, + trimMode: .workspaceLocal + ) + } + + static func tmuxWorkspacePaneWindowUnreadRects( + workspace: Workspace, + notificationStore: TerminalNotificationStore, + layoutSnapshot: LayoutSnapshot? + ) -> [CGRect] { + tmuxWorkspacePaneRects( + workspace: workspace, + notificationStore: notificationStore, + layoutSnapshot: layoutSnapshot, + includeContainerOffset: true, + trimMode: .windowContent + ) + } + + private static func tmuxLayoutSnapshotHasRenderableGeometry(_ snapshot: LayoutSnapshot) -> Bool { + snapshot.containerFrame.width > 1 && + snapshot.containerFrame.height > 1 && + snapshot.panes.contains { pane in + pane.frame.width > 1 && pane.frame.height > 1 + } + } + static func resolveGhosttyAppearanceConfig( reason: String = "unspecified", backgroundOverride: NSColor? = nil, diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index a2c8ccf6e398..bffec9578e73 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2388,6 +2388,161 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertEqual(output, "BEFORE\nAFTER", output) } + func testShellIntegrationPublishesOnlyWorkspaceScopedCmuxEnvironmentToTmuxServerAutomatically() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-tmux-publish-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("tmux.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("tmux", isDirectory: false), + contents: """ + #!/bin/sh + if [ "$1" = "show-environment" ] && [ "$2" = "-g" ]; then + exit 0 + fi + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + _ = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: "_cmux_preexec tmux; print -r -- READY", + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "/tmp/cmux-current.sock", + "CMUX_TAG": "feat-tmux-notification-attention-state", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_SURFACE_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + let log = (try? String(contentsOf: logPath, encoding: .utf8)) ?? "" + XCTAssertTrue(log.contains("set-environment -g CMUX_TAG feat-tmux-notification-attention-state"), log) + XCTAssertTrue(log.contains("set-environment -g CMUX_SOCKET_PATH /tmp/cmux-current.sock"), log) + XCTAssertTrue(log.contains("set-environment -g CMUX_WORKSPACE_ID 11111111-1111-1111-1111-111111111111"), log) + XCTAssertFalse(log.contains("set-environment -g CMUX_SURFACE_ID"), log) + XCTAssertFalse(log.contains("set-environment -g CMUX_PANEL_ID"), log) + } + + func testShellIntegrationClearsStaleSurfaceScopedTmuxEnvironmentAutomatically() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-tmux-clear-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("tmux.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("tmux", isDirectory: false), + contents: """ + #!/bin/sh + if [ "$1" = "show-environment" ] && [ "$2" = "-g" ]; then + printf '%s\\n' 'CMUX_SURFACE_ID=99999999-9999-9999-9999-999999999999' + printf '%s\\n' 'CMUX_PANEL_ID=99999999-9999-9999-9999-999999999999' + exit 0 + fi + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + _ = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: "_cmux_preexec tmux; print -r -- READY", + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "/tmp/cmux-current.sock", + "CMUX_TAG": "feat-tmux-notification-attention-state", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_SURFACE_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + let log = (try? String(contentsOf: logPath, encoding: .utf8)) ?? "" + XCTAssertTrue(log.contains("set-environment -gu CMUX_SURFACE_ID"), log) + XCTAssertTrue(log.contains("set-environment -gu CMUX_PANEL_ID"), log) + } + + func testShellIntegrationRefreshesWorkspaceScopedCmuxEnvironmentFromTmuxWithoutOverwritingSurfaceScope() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-tmux-refresh-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("tmux", isDirectory: false), + contents: """ + #!/bin/sh + if [ "$1" = "show-environment" ] && [ "$2" = "-g" ]; then + printf '%s\\n' 'CMUX_SOCKET_PATH=/tmp/cmux-current.sock' + printf '%s\\n' 'CMUX_TAG=feat-tmux-notification-attention-state' + printf '%s\\n' 'CMUX_WORKSPACE_ID=11111111-1111-1111-1111-111111111111' + printf '%s\\n' 'CMUX_SURFACE_ID=99999999-9999-9999-9999-999999999999' + printf '%s\\n' 'CMUX_TAB_ID=11111111-1111-1111-1111-111111111111' + printf '%s\\n' 'CMUX_PANEL_ID=99999999-9999-9999-9999-999999999999' + exit 0 + fi + exit 0 + """ + ) + + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: "_cmux_precmd; print -r -- \"$CMUX_TAG|$CMUX_SOCKET_PATH|$CMUX_WORKSPACE_ID|$CMUX_SURFACE_ID|$CMUX_PANEL_ID\"", + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "TMUX": "/tmp/tmux-stale,123,0", + "CMUX_SOCKET_PATH": "/tmp/cmux-stale.sock", + "CMUX_TAG": "feat-tmux-integration-experiments", + "CMUX_WORKSPACE_ID": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + "CMUX_SURFACE_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_TAB_ID": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + XCTAssertEqual( + output, + "feat-tmux-notification-attention-state|/tmp/cmux-current.sock|11111111-1111-1111-1111-111111111111|22222222-2222-2222-2222-222222222222|22222222-2222-2222-2222-222222222222" + ) + } + + func testShellIntegrationReportsTTYFromTmuxWithoutUsingPanelScope() throws { + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + _CMUX_TTY_NAME=ttys999 + print -r -- "$(_cmux_report_tty_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_tty ttys999 --tab=11111111-1111-1111-1111-111111111111") + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, @@ -2401,7 +2556,8 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { private func runInteractiveZsh( cmuxLoadGhosttyIntegration: Bool, cmuxLoadShellIntegration: Bool, - command: String + command: String, + extraEnvironment: [String: String] = [:] ) throws -> String { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -2411,7 +2567,18 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { let userZdotdir = root.appendingPathComponent("zdotdir") try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true) - try "\n".write(to: userZdotdir.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) + let userZshEnvContents: String = { + if let path = extraEnvironment["PATH"] { + let escaped = path.replacingOccurrences(of: "\"", with: "\\\"") + return "export PATH=\"\(escaped)\"\n" + } + return "\n" + }() + try userZshEnvContents.write( + to: userZdotdir.appendingPathComponent(".zshenv"), + atomically: true, + encoding: .utf8 + ) let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -2445,6 +2612,9 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { process.environment?["CMUX_TAB_ID"] = "tab-test" process.environment?["CMUX_PANEL_ID"] = "panel-test" } + for (key, value) in extraEnvironment { + process.environment?[key] = value + } let stdout = Pipe() let stderr = Pipe() @@ -2468,6 +2638,61 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertEqual(process.terminationStatus, 0, error) return output.trimmingCharacters(in: .whitespacesAndNewlines) } + + private func writeExecutableScript(at url: URL, contents: String) throws { + try contents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"] + ) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(pathBuf, ptr, maxPathLength - 1) + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: code, + userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"] + ) + } + + guard Darwin.listen(fd, 1) == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: code, + userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"] + ) + } + + return fd + } } final class BrowserInstallDetectorTests: XCTestCase { diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index fa358cf6c01f..c0432c8e0159 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -767,6 +767,105 @@ final class MenuBarBadgeLabelFormatterTests: XCTestCase { } } +@MainActor +final class FocusedNotificationIndicatorTests: XCTestCase { + func testFocusedNotificationIndicatorRemainsVisibleAfterFocusedNotificationIsRead() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + store.addNotification( + tabId: workspace.id, + surfaceId: panelId, + title: "Focused", + subtitle: "", + body: "" + ) + + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + + store.markRead(forTabId: workspace.id, surfaceId: panelId) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + + store.clearFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + } + + func testNewNotificationOnDifferentSurfaceClearsPreviousFocusedReadIndicator() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split workspace setup") + return + } + + workspace.focusPanel(rightPanel.id) + + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: rightPanel.id) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id)) + + store.addNotification( + tabId: workspace.id, + surfaceId: leftPanelId, + title: "Left", + subtitle: "", + body: "" + ) + + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id)) + } +} + final class NotificationMenuSnapshotBuilderTests: XCTestCase { func testSnapshotCountsUnreadAndLimitsRecentItems() { diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index d69423788ccd..ea2d9b1e3b89 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -517,6 +517,80 @@ final class TabManagerNotificationFocusTests: XCTestCase { XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID())) } + + func testFocusTabFromNotificationDismissesUnreadWithDismissFlash() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let defaults = UserDefaults.standard + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey) + let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey) + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) + defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalExperimentEnabled { + defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey) + } + if let originalExperimentTarget { + defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey) + } + } + + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + workspace.focusPanel(leftPanelId) + store.addNotification( + tabId: workspace.id, + surfaceId: rightPanel.id, + title: "Unread", + subtitle: "", + body: "Right pane should dismiss attention when focused from a notification" + ) + + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: rightPanel.id)) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id)) + XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0) + + XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) + + let expectation = XCTestExpectation(description: "notification focus flash") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: rightPanel.id)) + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id)) + XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 1) + XCTAssertEqual(workspace.tmuxWorkspaceFlashPanelId, rightPanel.id) + XCTAssertEqual(workspace.tmuxWorkspaceFlashReason, .notificationDismiss) + } } @@ -806,6 +880,178 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { } +@MainActor +final class TabManagerFocusedNotificationIndicatorTests: XCTestCase { + func testFocusPanelDismissesUnreadNotificationWithDismissFlash() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let defaults = UserDefaults.standard + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey) + let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey) + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) + defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalExperimentEnabled { + defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey) + } + if let originalExperimentTarget { + defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey) + } + } + + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + store.addNotification( + tabId: workspace.id, + surfaceId: leftPanelId, + title: "Unread", + subtitle: "", + body: "Left pane should dismiss attention when focused" + ) + + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: leftPanelId)) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: leftPanelId)) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0) + + workspace.focusPanel(leftPanelId) + + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: leftPanelId)) + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: leftPanelId)) + XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 1) + XCTAssertEqual(workspace.tmuxWorkspaceFlashPanelId, leftPanelId) + XCTAssertEqual(workspace.tmuxWorkspaceFlashReason, .notificationDismiss) + } + + func testDismissNotificationOnDirectInteractionClearsFocusedNotificationIndicator() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + + XCTAssertTrue( + manager.dismissNotificationOnDirectInteraction(tabId: workspace.id, surfaceId: panelId) + ) + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + } + + func testDismissNotificationOnDirectInteractionTriggersDismissFlashForFocusedIndicatorOnly() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let defaults = UserDefaults.standard + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey) + let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey) + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) + defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalExperimentEnabled { + defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey) + } + if let originalExperimentTarget { + defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey) + } + } + + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0) + + XCTAssertTrue( + manager.dismissNotificationOnDirectInteraction(tabId: workspace.id, surfaceId: panelId) + ) + + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashToken, + 1, + "Expected dismissing a focused-read indicator to emit a dismiss flash even when unread is already cleared" + ) + XCTAssertEqual(workspace.tmuxWorkspaceFlashPanelId, panelId) + XCTAssertEqual(workspace.tmuxWorkspaceFlashReason, .notificationDismiss) + } +} + @MainActor final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 6707eda48c83..39de0903ebdf 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1248,4 +1248,67 @@ final class MarkdownPanelPointerObserverViewTests: XCTestCase { XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30))) } } + +@MainActor +final class TmuxWorkspacePaneOverlayTests: XCTestCase { + func testTmuxWorkspacePaneOverlayModelTracksFlashReason() { + let model = TmuxWorkspacePaneOverlayModel() + let initialState = TmuxWorkspacePaneOverlayRenderState( + workspaceId: UUID(), + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 1, + flashReason: .notificationArrival + ) + let laterState = TmuxWorkspacePaneOverlayRenderState( + workspaceId: initialState.workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 2, + flashReason: .navigation + ) + + model.apply(initialState) + model.apply(laterState) + + XCTAssertEqual(model.flashReason, .navigation) + } + + func testNavigationFlashUsesNonNotificationPresentation() { + XCTAssertNotEqual( + WorkspaceAttentionCoordinator.flashStyle(for: .navigation), + WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival) + ) + } + + func testNavigationFlashUsesNonNeutralAccent() { + XCTAssertEqual( + WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent, + .navigationTeal + ) + } + + func testTmuxWorkspacePaneExactRectReturnsContentRelativeFrameForDescendantView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected contentView") + return + } + + let targetView = NSView(frame: NSRect(x: 120, y: 48, width: 300, height: 200)) + contentView.addSubview(targetView) + + XCTAssertEqual( + ContentView.tmuxWorkspacePaneExactRect(for: targetView, in: contentView), + CGRect(x: 120, y: 48, width: 300, height: 200) + ) + } +} #endif diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index d3759a0cfad6..2d6e8fa5fa08 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -1,4 +1,6 @@ import XCTest +import CoreGraphics +import Bonsplit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -76,4 +78,84 @@ final class WorkspaceContentViewVisibilityTests: XCTestCase { ) ) } + + func testTmuxWorkspacePaneOverlayRectReturnsMatchingPaneFrame() { + let paneID = PaneID(id: UUID()) + let snapshot = LayoutSnapshot( + containerFrame: PixelRect(x: 200, y: 32, width: 1200, height: 800), + panes: [ + PaneGeometry( + paneId: paneID.id.uuidString, + frame: PixelRect(x: 877.5, y: 32, width: 500, height: 320), + selectedTabId: nil, + tabIds: [] + ) + ], + focusedPaneId: paneID.id.uuidString, + timestamp: 0 + ) + + XCTAssertEqual( + WorkspaceContentView.tmuxWorkspacePaneOverlayRect( + layoutSnapshot: snapshot, + paneId: paneID + ), + CGRect(x: 677.5, y: 30, width: 500, height: 290) + ) + } + + @MainActor + func testTmuxWorkspacePaneUnreadRectsIncludeFocusedReadIndicator() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let surfaceId = workspace.surfaceIdFromPanelId(panelId), + let paneId = workspace.paneId(forPanelId: panelId) else { + XCTFail("Expected selected workspace geometry") + return + } + + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + + let snapshot = LayoutSnapshot( + containerFrame: PixelRect(x: 200, y: 32, width: 1200, height: 800), + panes: [ + PaneGeometry( + paneId: paneId.id.uuidString, + frame: PixelRect(x: 877.5, y: 32, width: 500, height: 320), + selectedTabId: surfaceId.uuid.uuidString, + tabIds: [surfaceId.uuid.uuidString] + ) + ], + focusedPaneId: paneId.id.uuidString, + timestamp: 0 + ) + + XCTAssertEqual( + WorkspaceContentView.tmuxWorkspacePaneUnreadRects( + workspace: workspace, + notificationStore: store, + layoutSnapshot: snapshot + ), + [CGRect(x: 677.5, y: 30, width: 500, height: 290)] + ) + } } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 797c96a93ec4..6123d350292d 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -469,3 +469,976 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } } + +final class CLINotifyProcessIntegrationTests: XCTestCase { + private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + private final class MockSocketServerState: @unchecked Sendable { + private let lock = NSLock() + private(set) var commands: [String] = [] + + func append(_ command: String) { + lock.lock() + commands.append(command) + lock.unlock() + } + } + + private func makeSocketPath(_ name: String) -> String { + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cli-\(name.prefix(6))-\(shortID).sock") + .path + } + + private func bundledCLIPath() throws -> String { + let fileManager = FileManager.default + let appBundleURL = Bundle(for: Self.self) + .bundleURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let enumerator = fileManager.enumerator( + at: appBundleURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + + while let item = enumerator?.nextObject() as? URL { + guard item.lastPathComponent == "cmux", + item.path.contains(".app/Contents/Resources/bin/cmux") else { + continue + } + return item.path + } + + throw XCTSkip("Bundled cmux CLI not found in \(appBundleURL.path)") + } + + private func runProcess( + executablePath: String, + arguments: [String], + environment: [String: String], + timeout: TimeInterval + ) -> ProcessRunResult { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return ProcessRunResult( + status: -1, + stdout: "", + stderr: String(describing: error), + timedOut: false + ) + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return ProcessRunResult( + status: process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"] + ) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(pathBuf, ptr, maxPathLength - 1) + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: code, + userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"] + ) + } + + guard Darwin.listen(fd, 1) == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: code, + userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"] + ) + } + + return fd + } + + private func startMockServer( + listenerFD: Int32, + state: MockSocketServerState, + handler: @escaping @Sendable (String) -> String + ) -> XCTestExpectation { + let handled = expectation(description: "cli mock socket handled") + DispatchQueue.global(qos: .userInitiated).async { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + handled.fulfill() + return + } + defer { + Darwin.close(clientFD) + handled.fulfill() + } + + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + return + } + if count == 0 { return } + pending.append(buffer, count: count) + + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0.. String { + var payload: [String: Any] = ["id": id, "ok": ok] + if let result { + payload["result"] = result + } + if let error { + payload["error"] = error + } + let data = try? JSONSerialization.data(withJSONObject: payload, options: []) + return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" + } + + @MainActor + func testNotifyFallsBackFromStaleCallerWorkspaceAndSurfaceIDs() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("notify") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let currentWorkspace = "11111111-1111-1111-1111-111111111111" + let currentSurface = "22222222-2222-2222-2222-222222222222" + let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String { + let params = payload["params"] as? [String: Any] ?? [:] + switch method { + case "surface.list": + let workspaceId = params["workspace_id"] as? String + if workspaceId == staleWorkspace { + return self.v2Response( + id: id, + ok: false, + error: ["code": "not_found", "message": "Workspace not found"] + ) + } + if workspaceId == currentWorkspace { + return self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": currentSurface, + "ref": "surface:1", + "index": 0, + "focused": true + ] + ] + ] + ) + } + case "workspace.current": + return self.v2Response( + id: id, + ok: true, + result: ["workspace_id": currentWorkspace] + ) + default: + break + } + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + + if line == "notify_target \(currentWorkspace) \(currentSurface) Notification||" { + return "OK" + } + return "ERROR: Unexpected command \(line)" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = staleWorkspace + environment["CMUX_SURFACE_ID"] = staleSurface + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["notify"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + XCTAssertTrue( + state.commands.contains("notify_target \(currentWorkspace) \(currentSurface) Notification||"), + "Expected notify_target to use current workspace and surface, saw \(state.commands)" + ) + } + + @MainActor + func testTriggerFlashFallsBackFromStaleCallerWorkspaceAndSurfaceIDs() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("flash") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let currentWorkspace = "11111111-1111-1111-1111-111111111111" + let currentSurface = "22222222-2222-2222-2222-222222222222" + let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.v2Response( + id: "unknown", + ok: false, + error: ["code": "unexpected", "message": "Unexpected payload"] + ) + } + + let params = payload["params"] as? [String: Any] ?? [:] + switch method { + case "surface.list": + let workspaceId = params["workspace_id"] as? String + if workspaceId == staleWorkspace { + return self.v2Response( + id: id, + ok: false, + error: ["code": "not_found", "message": "Workspace not found"] + ) + } + if workspaceId == currentWorkspace { + return self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": currentSurface, + "ref": "surface:1", + "index": 0, + "focused": true + ] + ] + ] + ) + } + case "workspace.current": + return self.v2Response( + id: id, + ok: true, + result: ["workspace_id": currentWorkspace] + ) + case "surface.trigger_flash": + let workspaceId = params["workspace_id"] as? String + let surfaceId = params["surface_id"] as? String + if workspaceId == currentWorkspace, surfaceId == currentSurface { + return self.v2Response(id: id, ok: true, result: [:]) + } + default: + break + } + + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = staleWorkspace + environment["CMUX_SURFACE_ID"] = staleSurface + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["trigger-flash"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + XCTAssertTrue( + state.commands.contains { command in + guard let data = command.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let method = payload["method"] as? String, + method == "surface.trigger_flash" else { + return false + } + let params = payload["params"] as? [String: Any] ?? [:] + return (params["workspace_id"] as? String) == currentWorkspace + && (params["surface_id"] as? String) == currentSurface + }, + "Expected surface.trigger_flash to use current workspace and surface, saw \(state.commands)" + ) + } + + @MainActor + func testNotifyPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("notify-tty") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let callerTTY = "/dev/ttys777" + let workspaceId = "11111111-1111-1111-1111-111111111111" + let callerSurface = "22222222-2222-2222-2222-222222222222" + let focusedSurface = "33333333-3333-3333-3333-333333333333" + let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if line == "notify_target \(workspaceId) \(callerSurface) Notification||" { + return "OK" + } + + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return "ERROR: Unexpected command \(line)" + } + + let params = payload["params"] as? [String: Any] ?? [:] + switch method { + case "surface.list": + let requestedWorkspace = params["workspace_id"] as? String + if requestedWorkspace == staleWorkspace { + return self.v2Response( + id: id, + ok: false, + error: ["code": "not_found", "message": "Workspace not found"] + ) + } + if requestedWorkspace == workspaceId { + return self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": callerSurface, + "ref": "surface:1", + "index": 0, + "focused": false + ], + [ + "id": focusedSurface, + "ref": "surface:2", + "index": 1, + "focused": true + ] + ] + ] + ) + } + case "workspace.current": + return self.v2Response( + id: id, + ok: true, + result: ["workspace_id": workspaceId] + ) + case "debug.terminals": + return self.v2Response( + id: id, + ok: true, + result: [ + "count": 2, + "terminals": [ + [ + "workspace_id": workspaceId, + "surface_id": callerSurface, + "tty": callerTTY + ], + [ + "workspace_id": workspaceId, + "surface_id": focusedSurface, + "tty": "/dev/ttys778" + ] + ] + ] + ) + default: + break + } + + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = staleWorkspace + environment["CMUX_SURFACE_ID"] = staleSurface + environment["CMUX_CLI_TTY_NAME"] = callerTTY + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["notify"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + XCTAssertTrue( + state.commands.contains("notify_target \(workspaceId) \(callerSurface) Notification||"), + "Expected notify_target to use caller tty surface, saw \(state.commands)" + ) + XCTAssertFalse( + state.commands.contains("notify_target \(workspaceId) \(focusedSurface) Notification||"), + "Focused surface should not win over caller tty, saw \(state.commands)" + ) + } + + @MainActor + func testNotifyInTmuxPrefersCallerTTYOverStaleValidSurfaceID() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("notify-tmux-tty") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let callerTTY = "/dev/ttys777" + let workspaceId = "11111111-1111-1111-1111-111111111111" + let callerSurface = "22222222-2222-2222-2222-222222222222" + let staleSurface = "33333333-3333-3333-3333-333333333333" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if line == "notify_target \(workspaceId) \(callerSurface) Notification||" { + return "OK" + } + if line == "notify_target \(workspaceId) \(staleSurface) Notification||" { + return "OK" + } + + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return "ERROR: Unexpected command \(line)" + } + + let params = payload["params"] as? [String: Any] ?? [:] + switch method { + case "surface.list": + let requestedWorkspace = params["workspace_id"] as? String + if requestedWorkspace == workspaceId { + return self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": callerSurface, + "ref": "surface:1", + "index": 0, + "focused": false + ], + [ + "id": staleSurface, + "ref": "surface:2", + "index": 1, + "focused": true + ] + ] + ] + ) + } + case "debug.terminals": + return self.v2Response( + id: id, + ok: true, + result: [ + "count": 2, + "terminals": [ + [ + "workspace_id": workspaceId, + "surface_id": callerSurface, + "tty": callerTTY + ], + [ + "workspace_id": workspaceId, + "surface_id": staleSurface, + "tty": "/dev/ttys778" + ] + ] + ] + ) + default: + break + } + + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = workspaceId + environment["CMUX_SURFACE_ID"] = staleSurface + environment["CMUX_CLI_TTY_NAME"] = callerTTY + environment["TMUX"] = "/tmp/tmux-current,123,0" + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["notify"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + XCTAssertTrue( + state.commands.contains("notify_target \(workspaceId) \(callerSurface) Notification||"), + "Expected notify_target to use caller tty surface in tmux, saw \(state.commands)" + ) + XCTAssertFalse( + state.commands.contains("notify_target \(workspaceId) \(staleSurface) Notification||"), + "Stale env surface should not win inside tmux, saw \(state.commands)" + ) + } + + @MainActor + func testTriggerFlashPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("flash-tty") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let callerTTY = "/dev/ttys777" + let workspaceId = "11111111-1111-1111-1111-111111111111" + let callerSurface = "22222222-2222-2222-2222-222222222222" + let focusedSurface = "33333333-3333-3333-3333-333333333333" + let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.v2Response( + id: "unknown", + ok: false, + error: ["code": "unexpected", "message": "Unexpected payload"] + ) + } + + let params = payload["params"] as? [String: Any] ?? [:] + switch method { + case "surface.list": + let requestedWorkspace = params["workspace_id"] as? String + if requestedWorkspace == staleWorkspace { + return self.v2Response( + id: id, + ok: false, + error: ["code": "not_found", "message": "Workspace not found"] + ) + } + if requestedWorkspace == workspaceId { + return self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": callerSurface, + "ref": "surface:1", + "index": 0, + "focused": false + ], + [ + "id": focusedSurface, + "ref": "surface:2", + "index": 1, + "focused": true + ] + ] + ] + ) + } + case "workspace.current": + return self.v2Response( + id: id, + ok: true, + result: ["workspace_id": workspaceId] + ) + case "debug.terminals": + return self.v2Response( + id: id, + ok: true, + result: [ + "count": 2, + "terminals": [ + [ + "workspace_id": workspaceId, + "surface_id": callerSurface, + "tty": callerTTY + ], + [ + "workspace_id": workspaceId, + "surface_id": focusedSurface, + "tty": "/dev/ttys778" + ] + ] + ] + ) + case "surface.trigger_flash": + let requestedWorkspace = params["workspace_id"] as? String + let requestedSurface = params["surface_id"] as? String + if requestedWorkspace == workspaceId, requestedSurface == callerSurface { + return self.v2Response(id: id, ok: true, result: [:]) + } + default: + break + } + + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = staleWorkspace + environment["CMUX_SURFACE_ID"] = staleSurface + environment["CMUX_CLI_TTY_NAME"] = callerTTY + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["trigger-flash"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + XCTAssertTrue( + state.commands.contains { command in + guard let data = command.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let method = payload["method"] as? String, + method == "surface.trigger_flash" else { + return false + } + let params = payload["params"] as? [String: Any] ?? [:] + return (params["workspace_id"] as? String) == workspaceId + && (params["surface_id"] as? String) == callerSurface + }, + "Expected surface.trigger_flash to use caller tty surface, saw \(state.commands)" + ) + XCTAssertFalse( + state.commands.contains { command in + guard let data = command.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let method = payload["method"] as? String, + method == "surface.trigger_flash" else { + return false + } + let params = payload["params"] as? [String: Any] ?? [:] + return (params["workspace_id"] as? String) == workspaceId + && (params["surface_id"] as? String) == focusedSurface + }, + "Focused surface should not win over caller tty, saw \(state.commands)" + ) + } + + @MainActor + func testTriggerFlashInTmuxPrefersCallerTTYOverStaleValidSurfaceID() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("flash-tmux-tty") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let callerTTY = "/dev/ttys777" + let workspaceId = "11111111-1111-1111-1111-111111111111" + let callerSurface = "22222222-2222-2222-2222-222222222222" + let staleSurface = "33333333-3333-3333-3333-333333333333" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.v2Response( + id: "unknown", + ok: false, + error: ["code": "unexpected", "message": "Unexpected payload"] + ) + } + + let params = payload["params"] as? [String: Any] ?? [:] + switch method { + case "surface.list": + let requestedWorkspace = params["workspace_id"] as? String + if requestedWorkspace == workspaceId { + return self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": callerSurface, + "ref": "surface:1", + "index": 0, + "focused": false + ], + [ + "id": staleSurface, + "ref": "surface:2", + "index": 1, + "focused": true + ] + ] + ] + ) + } + case "debug.terminals": + return self.v2Response( + id: id, + ok: true, + result: [ + "count": 2, + "terminals": [ + [ + "workspace_id": workspaceId, + "surface_id": callerSurface, + "tty": callerTTY + ], + [ + "workspace_id": workspaceId, + "surface_id": staleSurface, + "tty": "/dev/ttys778" + ] + ] + ] + ) + case "surface.trigger_flash": + let requestedWorkspace = params["workspace_id"] as? String + let requestedSurface = params["surface_id"] as? String + if requestedWorkspace == workspaceId, + (requestedSurface == callerSurface || requestedSurface == staleSurface) { + return self.v2Response(id: id, ok: true, result: [:]) + } + default: + break + } + + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = workspaceId + environment["CMUX_SURFACE_ID"] = staleSurface + environment["CMUX_CLI_TTY_NAME"] = callerTTY + environment["TMUX"] = "/tmp/tmux-current,123,0" + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["trigger-flash"], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + XCTAssertTrue( + state.commands.contains { command in + guard let data = command.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let method = payload["method"] as? String, + method == "surface.trigger_flash" else { + return false + } + let params = payload["params"] as? [String: Any] ?? [:] + return (params["workspace_id"] as? String) == workspaceId + && (params["surface_id"] as? String) == callerSurface + }, + "Expected trigger-flash to use caller tty surface in tmux, saw \(state.commands)" + ) + XCTAssertFalse( + state.commands.contains { command in + guard let data = command.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let method = payload["method"] as? String, + method == "surface.trigger_flash" else { + return false + } + let params = payload["params"] as? [String: Any] ?? [:] + return (params["workspace_id"] as? String) == workspaceId + && (params["surface_id"] as? String) == staleSurface + }, + "Stale env surface should not win inside tmux, saw \(state.commands)" + ) + } +} diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 16e04e822990..1407d1ea55ba 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -1071,6 +1071,134 @@ final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { } +@MainActor +final class WorkspaceAttentionFlashTests: XCTestCase { + func testMoveFocusDoesNotTriggerWholePaneFlashTokenWhenWholePaneModeEnabled() { + let defaults = UserDefaults.standard + let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey) + let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey) + + defer { + if let originalExperimentEnabled { + defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey) + } + if let originalExperimentTarget { + defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey) + } + } + + defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) + defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0) + XCTAssertNil(workspace.tmuxWorkspaceFlashPanelId) + + workspace.moveFocus(direction: .left) + + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashToken, + 0, + "Expected moving focus left to avoid any workspace-pane flash" + ) + XCTAssertNil(workspace.tmuxWorkspaceFlashPanelId) + + workspace.moveFocus(direction: .right) + + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashToken, + 0, + "Expected moving focus right to avoid any workspace-pane flash" + ) + XCTAssertNil(workspace.tmuxWorkspaceFlashPanelId) + } + + func testMoveFocusSuppressesWorkspacePaneFlashWhenAnotherPaneOwnsUnreadAttention() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let notificationStore = TerminalNotificationStore.shared + let defaults = UserDefaults.standard + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey) + let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + defer { + notificationStore.replaceNotificationsForTesting([]) + notificationStore.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalExperimentEnabled { + defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey) + } + if let originalExperimentTarget { + defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey) + } + } + + notificationStore.replaceNotificationsForTesting([]) + notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = notificationStore + AppFocusState.overrideIsFocused = true + defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) + defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + workspace.moveFocus(direction: .left) + + notificationStore.addNotification( + tabId: workspace.id, + surfaceId: leftPanelId, + title: "Unread", + subtitle: "", + body: "Left pane owns notification attention" + ) + + XCTAssertTrue( + notificationStore.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: leftPanelId), + "Expected the left pane to own visible notification attention before moving focus" + ) + + let flashTokenBeforeNavigation = workspace.tmuxWorkspaceFlashToken + + workspace.moveFocus(direction: .right) + + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashToken, + flashTokenBeforeNavigation, + "Expected navigation flash to be suppressed while another pane owns notification attention" + ) + } +} + + @MainActor final class WorkspaceBrowserProfileSelectionTests: XCTestCase { private final class RejectingCreateTabDelegate: BonsplitDelegate {