diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index ab4b6e2c..4a22e3a1 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -51,6 +51,7 @@ _CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}" _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" +_CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" @@ -103,6 +104,19 @@ _cmux_report_tty_once() { } >/dev/null 2>&1 & disown } +_cmux_report_shell_activity_state() { + local state="$1" + [[ -n "$state" ]] || return 0 + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + [[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0 + _CMUX_SHELL_ACTIVITY_LAST="$state" + { + _cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + } >/dev/null 2>&1 & disown +} + _cmux_ports_kick() { # Lightweight: just tell the app to run a batched scan for this panel. # The app coalesces kicks across all panels and runs a single ps+lsof. @@ -291,10 +305,33 @@ _cmux_bash_cleanup() { _cmux_stop_pr_poll_loop } +_cmux_preexec_command() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + if [[ -z "$_CMUX_TTY_NAME" ]]; then + local t + t="$(tty 2>/dev/null || true)" + t="${t##*/}" + [[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" + fi + + _cmux_report_shell_activity_state running + _cmux_report_tty_once + _cmux_ports_kick + _cmux_stop_pr_poll_loop +} + +_cmux_bash_preexec_hook() { + _cmux_preexec_command +} + _cmux_prompt_command() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 + _cmux_report_shell_activity_state prompt local now=$SECONDS local pwd="$PWD" @@ -439,6 +476,17 @@ _cmux_install_prompt_command() { ;; esac fi + + if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then + if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then + builtin readonly _CMUX_BASH_PS0='${ _cmux_bash_preexec_hook; }' + else + builtin readonly _CMUX_BASH_PS0='$(_cmux_bash_preexec_hook >/dev/null)' + fi + if [[ "$PS0" != *"${_CMUX_BASH_PS0}"* ]]; then + PS0=$PS0"${_CMUX_BASH_PS0}" + fi + fi } # Ensure Resources/bin is at the front of PATH, and remove the app's diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 821f3d19..e9bbf235 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -55,6 +55,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20 typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 +typeset -g _CMUX_SHELL_ACTIVITY_LAST="" typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 @@ -110,6 +111,19 @@ _cmux_report_tty_once() { } >/dev/null 2>&1 &! } +_cmux_report_shell_activity_state() { + local state="$1" + [[ -n "$state" ]] || return 0 + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + [[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0 + _CMUX_SHELL_ACTIVITY_LAST="$state" + { + _cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + } >/dev/null 2>&1 &! +} + _cmux_ports_kick() { # Lightweight: just tell the app to run a batched scan for this panel. # The app coalesces kicks across all panels and runs a single ps+lsof. @@ -361,6 +375,7 @@ _cmux_preexec() { fi _CMUX_CMD_START=$EPOCHSECONDS + _cmux_report_shell_activity_state running # Heuristic: commands that may change git branch/dirty state without changing $PWD. local cmd="${1## }" @@ -384,6 +399,7 @@ _cmux_precmd() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 + _cmux_report_shell_activity_state prompt if [[ -z "$_CMUX_TTY_NAME" ]]; then local t diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index fc3b596c..5307606e 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1404,6 +1404,15 @@ class TabManager: ObservableObject { tab.updatePanelDirectory(panelId: surfaceId, directory: normalized) } + func updateSurfaceShellActivity( + tabId: UUID, + surfaceId: UUID, + state: Workspace.PanelShellActivityState + ) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.updatePanelShellActivityState(panelId: surfaceId, state: state) + } + private func normalizeDirectory(_ directory: String) -> String { let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return directory } @@ -1797,7 +1806,7 @@ class TabManager: ObservableObject { guard tab.panels[surfaceId] != nil else { return } if let terminalPanel = tab.terminalPanel(for: surfaceId), - terminalPanel.needsConfirmClose() { + tab.panelNeedsConfirmClose(panelId: surfaceId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { guard confirmClose( title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index bf4862c6..619ac53c 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -320,7 +320,9 @@ class TerminalController { private final class SocketFastPathState: @unchecked Sendable { private let queue = DispatchQueue(label: "com.cmux.socket-fast-path") private var lastReportedDirectories: [SocketSurfaceKey: String] = [:] + private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:] private let maxTrackedDirectories = 4096 + private let maxTrackedShellStates = 4096 func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool { let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) @@ -335,6 +337,24 @@ class TerminalController { return true } } + + func shouldPublishShellActivity( + workspaceId: UUID, + panelId: UUID, + state: Workspace.PanelShellActivityState + ) -> Bool { + let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) + return queue.sync { + if lastReportedShellStates[key] == state { + return false + } + if lastReportedShellStates.count >= maxTrackedShellStates { + lastReportedShellStates.removeAll(keepingCapacity: true) + } + lastReportedShellStates[key] = state + return true + } + } } private static let socketFastPathState = SocketFastPathState() @@ -362,6 +382,21 @@ class TerminalController { return trimmed } + nonisolated static func parseReportedShellActivityState( + _ rawState: String + ) -> Workspace.PanelShellActivityState? { + switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "prompt", "idle": + return .promptIdle + case "running", "busy", "command": + return .commandRunning + case "unknown", "clear": + return .unknown + default: + return nil + } + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -1456,6 +1491,9 @@ class TerminalController { case "ports_kick": return portsKick(args) + case "report_shell_state": + return reportShellState(args) + case "report_pwd": return reportPwd(args) @@ -9701,6 +9739,7 @@ class TerminalController { report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel + report_shell_state [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command report_pwd [--tab=X] [--panel=Y] - Report current working directory clear_ports [--tab=X] [--panel=Y] - Clear listening ports sidebar_state [--tab=X] - Dump sidebar metadata @@ -13599,6 +13638,72 @@ class TerminalController { return result } + private func reportShellState(_ args: String) -> String { + let parsed = parseOptions(args) + guard let rawState = parsed.positional.first, !rawState.isEmpty else { + return "ERROR: Missing shell state — usage: report_shell_state [--tab=X] [--panel=Y]" + } + guard let state = Self.parseReportedShellActivityState(rawState) else { + return "ERROR: Invalid shell state '\(rawState)' — expected prompt or running" + } + + if let scope = Self.explicitSocketScope(options: parsed.options) { + guard Self.socketFastPathState.shouldPublishShellActivity( + workspaceId: scope.workspaceId, + panelId: scope.panelId, + state: state + ) else { + return "OK" + } + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return } + tabManager.updateSurfaceShellActivity(tabId: scope.workspaceId, surfaceId: scope.panelId, state: state) + } + return "OK" + } + + guard let tabManager else { return "ERROR: TabManager not available" } + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: report_shell_state [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + tabManager.updateSurfaceShellActivity(tabId: tab.id, surfaceId: surfaceId, state: state) + } + return result + } + private func clearPorts(_ args: String) -> String { let parsed = parseOptions(args) var result = "OK" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1025edf2..9326d6e1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1838,6 +1838,7 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } + panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() } @@ -3526,9 +3527,9 @@ final class Workspace: Identifiable, ObservableObject { /// Check if any panel needs close confirmation func needsConfirmClose() -> Bool { - for panel in panels.values { + for (panelId, panel) in panels { if let terminalPanel = panel as? TerminalPanel, - terminalPanel.needsConfirmClose() { + panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { return true } } @@ -4617,7 +4618,7 @@ extension Workspace: BonsplitDelegate { // If confirmation is required, Bonsplit will call into this delegate and we must return false. // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass // this gating on the second pass. - if terminalPanel.needsConfirmClose() { + if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { clearStagedClosedBrowserRestoreSnapshot(for: tab.id) if pendingCloseConfirmTabIds.contains(tab.id) { return false @@ -4717,6 +4718,7 @@ extension Workspace: BonsplitDelegate { manualUnreadPanelIds.remove(panelId) manualUnreadMarkedAt.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) + panelShellActivityStates.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) @@ -4896,6 +4898,7 @@ extension Workspace: BonsplitDelegate { pinnedPanelIds.remove(panelId) manualUnreadPanelIds.remove(panelId) panelSubscriptions.removeValue(forKey: panelId) + panelShellActivityStates.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) @@ -4933,7 +4936,7 @@ extension Workspace: BonsplitDelegate { if forceCloseTabIds.contains(tab.id) { continue } if let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId), - terminalPanel.needsConfirmClose() { + panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { pendingPaneClosePanelIds.removeValue(forKey: pane.id) return false }