fix: honor shell state for close confirmation

This commit is contained in:
Lawrence Chen 2026-03-13 09:15:01 -07:00
parent cfa7b1d1a6
commit a99ee15672
No known key found for this signature in database
5 changed files with 186 additions and 5 deletions

View file

@ -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

View file

@ -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

View file

@ -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."),

View file

@ -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 <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
report_shell_state <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
sidebar_state [--tab=X] - Dump sidebar metadata
@ -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 <prompt|running> [--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 <prompt|running> [--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"

View file

@ -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
}