Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2860,6 +2860,44 @@ class GhosttyApp {
}
}
}
case GHOSTTY_ACTION_TMUX_STATE:
guard let terminalSurface = surfaceView.terminalSurface else { return true }
let tmuxState = action.action.tmux_state
DispatchQueue.main.async {
switch tmuxState {
case GHOSTTY_TMUX_STATE_ENTERED:
terminalSurface.tmuxActive = true
#if DEBUG
dlog("tmux.entered tab=\(surfaceView.tabId?.uuidString.prefix(5) ?? "nil") surface=\(terminalSurface.id.uuidString.prefix(5))")
#endif
case GHOSTTY_TMUX_STATE_EXITED:
terminalSurface.tmuxActive = false
#if DEBUG
dlog("tmux.exited tab=\(surfaceView.tabId?.uuidString.prefix(5) ?? "nil") surface=\(terminalSurface.id.uuidString.prefix(5))")
#endif
case GHOSTTY_TMUX_STATE_WINDOWS_CHANGED:
// The tmux window/pane layout changed. Output is written
// directly to the surface terminal by the stream handler's
// octal decoder, so no renderer pointer swap is needed.
if let surface = terminalSurface.surface {
let paneCount = ghostty_surface_tmux_pane_count(surface)
let windowCount = ghostty_surface_tmux_window_count(surface)
#if DEBUG
dlog("tmux.windowsChanged windows=\(windowCount) panes=\(paneCount)")
#endif
_ = paneCount
_ = windowCount
}
Comment on lines +2878 to +2890

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 windows_changed always snaps the renderer to pane index 0

Every GHOSTTY_TMUX_STATE_WINDOWS_CHANGED event calls ghostty_surface_tmux_pane_ids(surface, &paneId, 1) to grab one ID and immediately makes it the active renderer. "First pane" here means whatever ordering the C API returns, with no memory of which pane was previously active.

In practice this fires on any tmux layout change (new window, pane split, pane close). Each such event will silently jump the user's view to pane 0, even if they were looking at a different pane a moment before. Consider tracking the last-set active pane and only auto-switching when the previously active pane is no longer present (i.e. it was deleted).

default:
break
}
NotificationCenter.default.post(
name: .ghosttyTmuxStateChanged,
object: terminalSurface,
userInfo: ["state": tmuxState]
)
}
Comment on lines +2863 to +2899

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find the definition of liveSurfaceForGhosttyAccess and understand how it validates surfaces
rg -A 10 "func liveSurfaceForGhosttyAccess" Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 660


🏁 Script executed:

# Check how TerminalSurface.surface can be replaced/rebound
rg -B 2 -A 3 "terminalSurface.surface\s*=" Sources/GhosttyTerminalView.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 335


🏁 Script executed:

# Look for similar async dispatch patterns that guard against stale surfaces
rg -B 3 -A 8 "DispatchQueue.main.async.*target.target.surface" Sources/GhosttyTerminalView.swift | head -80

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check if other tmux-related operations guard the surface
rg -B 5 -A 5 "ghostty_surface_tmux_" Sources/GhosttyTerminalView.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 1113


🏁 Script executed:

# Verify how surface lifecycle/rebinding works
rg -B 2 -A 5 "\.surface\s*=" Sources/GhosttyTerminalView.swift | grep -A 5 "terminalSurface\|TerminalSurface" | head -60

Repository: manaflow-ai/cmux

Length of output: 449


🏁 Script executed:

# Find the handleAction method signature to understand the target parameter
rg -B 5 "func handleAction.*target.*action" Sources/GhosttyTerminalView.swift | head -40

Repository: manaflow-ai/cmux

Length of output: 421


🏁 Script executed:

# Check what target.target.surface represents at the call site
rg -B 10 "case GHOSTTY_ACTION_TMUX_STATE:" Sources/GhosttyTerminalView.swift | head -30

Repository: manaflow-ai/cmux

Length of output: 612


🏁 Script executed:

# Search for other action handlers to see if they use similar guards
rg -B 2 -A 8 "case GHOSTTY_ACTION_" Sources/GhosttyTerminalView.swift | grep -A 8 "DispatchQueue.main.async" | head -60

Repository: manaflow-ai/cmux

Length of output: 2485


🏁 Script executed:

# Verify if surfaces can truly be unregistered mid-flight during async operations
rg -B 3 -A 5 "unregisterRuntimeSurface\|registry.unregister" Sources/GhosttyTerminalView.swift | head -80

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check the exact line where target is obtained in handleAction
sed -n '2800,2920p' Sources/GhosttyTerminalView.swift | grep -B 30 "GHOSTTY_ACTION_TMUX_STATE"

Repository: manaflow-ai/cmux

Length of output: 1619


🏁 Script executed:

# Find the callback registration and invocation to understand what target contains
rg -B 5 -A 10 "surfaceCallbackContext" Sources/GhosttyTerminalView.swift | grep -A 10 "Unmanaged\|handleAction" | head -60

Repository: manaflow-ai/cmux

Length of output: 1444


🏁 Script executed:

# Look for where ghostty_surface callbacks are registered and how target is populated
rg "ghostty_surface_on_action" Sources/GhosttyTerminalView.swift -B 3 -A 3

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check the actual closure at handleAction call site to see when it's invoked
rg -B 15 "handleAction.*target.*action" Sources/GhosttyTerminalView.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 1635


🏁 Script executed:

# Search for similar patterns where ghostty_surface_t is captured before async dispatch
rg -B 5 -A 10 "let.*surface.*=.*target.target.surface" Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check other critical surface operations that happen in async closures
rg -B 8 -A 8 "ghostty_surface_.*\(" Sources/GhosttyTerminalView.swift | grep -B 8 -A 8 "DispatchQueue.main.async" | head -80

Repository: manaflow-ai/cmux

Length of output: 2877


🏁 Script executed:

# Check the Ghostty C API to understand what target.target.surface is
rg "ghostty_target" Sources/GhosttyTerminalView.swift -B 2 -A 2 | head -40

Repository: manaflow-ai/cmux

Length of output: 528


🏁 Script executed:

# Find documentation or comments about what the target parameter contains
grep -r "target.target.surface" Sources/ --include="*.swift"

Repository: manaflow-ai/cmux

Length of output: 199


🏁 Script executed:

# Look at how other handlers use the target parameter
sed -n '2600,2950p' Sources/GhosttyTerminalView.swift | grep -B 5 -A 10 "target.target"

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check if there are any other uses of target in handleAction
rg "private func handleAction" -A 200 Sources/GhosttyTerminalView.swift | grep "target\." | head -20

Repository: manaflow-ai/cmux

Length of output: 215


🏁 Script executed:

# Verify the exact pattern used in clipboard callback - does it capture the source surface?
sed -n '2380,2450p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 3253


🏁 Script executed:

# Get the full clipboard callback pattern to see how it captures and validates
sed -n '2380,2430p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 2510


🏁 Script executed:

# Check the deinit to understand surface lifecycle risks
sed -n '1050,1130p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 3135


🏁 Script executed:

# See if surfaces can actually be replaced during operation
rg "surface = nil" Sources/GhosttyTerminalView.swift -B 3 -A 3

Repository: manaflow-ai/cmux

Length of output: 1066


🏁 Script executed:

# Verify if liveSurfaceForGhosttyAccess is the proper validation function
sed -n '970,1010p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 1998


🏁 Script executed:

# Get the full read_clipboard_cb to see the capture + validate pattern
sed -n '2345,2430p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 4113


🏁 Script executed:

# Verify the surfaces ARE actually changed/replaced during normal operation
rg "self.surface = " Sources/GhosttyTerminalView.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 238


🏁 Script executed:

# Check if terminalSurface reference is stable even if its .surface property changes
rg "class TerminalSurface" -A 30 Sources/GhosttyTerminalView.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 1624


🏁 Script executed:

# Find the complete clipboard callback including the validation pattern
sed -n '2545,2620p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 3622


🏁 Script executed:

# Verify the exact pattern for capturing and validating in async closures
rg -B 8 -A 12 "runtimeSurface == requestSurface" Sources/GhosttyTerminalView.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 1073


🏁 Script executed:

# Check what happens if we try to access target in the async closure - is it captured?
sed -n '2863,2910p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 2402


🏁 Script executed:

# Look for other places where target.target.surface is captured before async dispatch
rg -B 5 -A 15 "let.*=.*target.target.surface" Sources/GhosttyTerminalView.swift | head -80

Repository: manaflow-ai/cmux

Length of output: 944


🏁 Script executed:

# Verify if the `target` parameter can be captured in the closure or goes out of scope
sed -n '2820,2870p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 2575


🏁 Script executed:

# Check lifecycle: can a TerminalSurface's ghostty_surface_t pointer change between callback and async closure?
rg -B 3 -A 5 "hasLiveSurface\|cmuxSurfacePointerAppearsLive" Sources/GhosttyTerminalView.swift | head -60

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check if target is available at the time of DispatchQueue.main.async or if it goes out of scope
sed -n '2840,2880p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 2334


🏁 Script executed:

# Look at the signature and see if target is passed by value (can be captured) or reference
rg "func handleAction" -A 5 Sources/GhosttyTerminalView.swift | head -15

Repository: manaflow-ai/cmux

Length of output: 462


🏁 Script executed:

# Verify what ghostty_target_s is and if it can be safely captured
rg -B 2 -A 2 "ghostty_target_s" Sources/GhosttyTerminalView.swift | head -30

Repository: manaflow-ai/cmux

Length of output: 528


🏁 Script executed:

# Check if surfaces are actually torn down/reparented in normal operation
rg "forceRefreshSurface\|teardown\|reparent" Sources/GhosttyTerminalView.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Verify the learning about surface re-reading after async operations
sed -n '1,100p' Sources/GhosttyTerminalView.swift | grep -A 10 "Learning:"

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check how liveSurfaceForGhosttyAccess handles stale surfaces
sed -n '970,1000p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 1629


🏁 Script executed:

# Look at the full liveSurfaceForGhosttyAccess implementation to understand the validation
sed -n '900,950p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 1816


🏁 Script executed:

# Check if target can actually be captured in a closure (it's a struct)
rg "ghostty_target" Sources/GhosttyTerminalView.swift -B 2 -A 2 | grep -A 5 "struct\|class"

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Look for examples where target parameters ARE captured before async
rg -B 8 -A 8 "let.*=.*target\." Sources/GhosttyTerminalView.swift | head -40

Repository: manaflow-ai/cmux

Length of output: 1558


🏁 Script executed:

# Verify the learning about surface reparenting to understand if this is a real scenario
rg "forceRefreshSurface\|teardown\|initial-present recovery" Sources/GhosttyTerminalView.swift -B 2 -A 5 | head -40

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check if target can be captured and if target.target.surface is a valid pointer at closure time
sed -n '2415,2445p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 1355


🏁 Script executed:

# Check if there are actual examples of surface rebinding during operation
rg "surface = ghostty_surface_new\|surface = nil" Sources/GhosttyTerminalView.swift -B 3 -A 1

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Verify if requestBackgroundSurfaceStartIfNeeded or similar can change surfaces
sed -n '1250,1320p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 3139


🏁 Script executed:

# Verify that target (a struct parameter) can actually be captured in the closure
python3 << 'EOF'
# Swift struct parameters can be captured in closures - this is standard Swift
# The ghostty_target_s is a struct, so target.target.surface is a value that can be captured
print("Swift closure capture behavior:")
print("✓ Function parameters (value types like structs) CAN be captured")
print("✓ target.target.surface is a raw pointer value that can be captured")
print("✓ The pointer value remains valid across the async dispatch")
EOF

Repository: manaflow-ai/cmux

Length of output: 284


🏁 Script executed:

# Verify the lifecycle: do surfaces actually get rebound to new pointers?
rg "requestBackgroundSurfaceStartIfNeeded\|surface.*ghostty_surface_new" Sources/GhosttyTerminalView.swift -B 2 -A 5 | head -40

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Final check: look at a complete timeline - does terminalSurface.surface actually get replaced?
sed -n '960,1020p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 3063


🏁 Script executed:

# Verify the complete flow: callback receives target at callback time, then async dispatch happens
sed -n '2655,2710p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 3047


Guard tmux callbacks against stale/replaced runtime surfaces.

Line 2866 dispatches to the main queue, but line 2880 then re-reads terminalSurface.surface instead of validating the source surface that raised this action. Surfaces can be torn down or reparented during layout recovery and workspace churn; if this TerminalSurface's C surface pointer changes between callback delivery and the async closure, the tmux event will mutate stale state and invoke ghostty_surface_tmux_* on a freed or incorrect pointer. Capture target.target.surface before dispatch, then require liveSurfaceForGhosttyAccess(reason:) == sourceSurface before any state mutation or pane selection.

Suggested guard
         case GHOSTTY_ACTION_TMUX_STATE:
             guard let terminalSurface = surfaceView.terminalSurface else { return true }
             let tmuxState = action.action.tmux_state
+            let sourceSurface = target.target.surface
             DispatchQueue.main.async {
+                let liveSurface = MainActor.assumeIsolated {
+                    terminalSurface.liveSurfaceForGhosttyAccess(reason: "tmux.state")
+                }
+                guard let liveSurface, liveSurface == sourceSurface else { return }
                 switch tmuxState {
                 case GHOSTTY_TMUX_STATE_ENTERED:
                     terminalSurface.tmuxActive = true
                     `#if` DEBUG
                     dlog("tmux.entered tab=\(surfaceView.tabId?.uuidString.prefix(5) ?? "nil") surface=\(terminalSurface.id.uuidString.prefix(5))")
                     `#endif`
                 case GHOSTTY_TMUX_STATE_EXITED:
                     terminalSurface.tmuxActive = false
                     `#if` DEBUG
                     dlog("tmux.exited tab=\(surfaceView.tabId?.uuidString.prefix(5) ?? "nil") surface=\(terminalSurface.id.uuidString.prefix(5))")
                     `#endif`
                 case GHOSTTY_TMUX_STATE_WINDOWS_CHANGED:
                     // Auto-set the renderer to the first pane so we can see tmux output
-                    if let surface = terminalSurface.surface {
-                        let paneCount = ghostty_surface_tmux_pane_count(surface)
-                        if paneCount > 0 {
-                            var paneId: UInt = 0
-                            let idCount = ghostty_surface_tmux_pane_ids(surface, &paneId, 1)
-                            if idCount > 0 {
-                                let success = ghostty_surface_tmux_set_active_pane(surface, paneId)
-                                `#if` DEBUG
-                                dlog("tmux.windowsChanged panes=\(paneCount) activePaneId=\(paneId) setActive=\(success)")
-                                `#endif`
-                            }
-                        }
+                    let paneCount = ghostty_surface_tmux_pane_count(liveSurface)
+                    if paneCount > 0 {
+                        var paneId: UInt = 0
+                        let idCount = ghostty_surface_tmux_pane_ids(liveSurface, &paneId, 1)
+                        if idCount > 0 {
+                            let success = ghostty_surface_tmux_set_active_pane(liveSurface, paneId)
+                            `#if` DEBUG
+                            dlog("tmux.windowsChanged panes=\(paneCount) activePaneId=\(paneId) setActive=\(success)")
+                            `#endif`
+                        }
                     }
                 default:
                     break
                 }
                 NotificationCenter.default.post(
                     name: .ghosttyTmuxStateChanged,
                     object: terminalSurface,
                     userInfo: ["state": tmuxState]
                 )
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 2863 - 2901, Capture the C
surface pointer before dispatching to the main queue and bail if it changed:
read let sourceSurface = terminalSurface.surface immediately before
DispatchQueue.main.async, then inside the async closure, before mutating
terminalSurface or calling ghostty_surface_tmux_* functions, guard that
terminalSurface.liveSurfaceForGhosttyAccess(reason: /* appropriate reason */) ==
sourceSurface (or otherwise compare terminalSurface.surface == sourceSurface)
and return early if it differs; update the GHOSTTY_ACTION_TMUX_STATE handling
(where terminalSurface and tmux pane selection occur) to use this guard so you
never call ghostty_surface_tmux_* on a stale/freed pointer.

return true
default:
return false
}
Expand Down Expand Up @@ -3130,6 +3168,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
@Published private(set) var keyboardCopyModeActive: Bool = false
/// Whether this surface is currently in tmux control mode (DCS 1000p).
Comment on lines 3170 to +3171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 tmuxActive is missing private(set) — inconsistent with similar state properties

Neighbouring state flags use @Published private(set) var:

@Published private(set) var keyboardCopyModeActive: Bool = false

tmuxActive is declared without private(set):

@Published var tmuxActive: Bool = false

Because the setter is called from inside GhosttyApp's action handler (a different type), private(set) would not compile as-is. A common pattern to keep external mutability explicit is to make the property private(set) and expose a dedicated fileprivate or internal setter method on TerminalSurface, or to restructure GhosttyApp so the mutation flows through TerminalSurface itself. As written, any code with a reference to the surface can write tmuxActive freely, bypassing the intended notification flow.

@Published var tmuxActive: Bool = false
private var searchNeedleCancellable: AnyCancellable?
var currentKeyStateIndicatorText: String? { surfaceView.currentKeyStateIndicatorText }

Expand Down Expand Up @@ -7185,6 +7225,7 @@ extension Notification.Name {
static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus")
static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload")
static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange")
static let ghosttyTmuxStateChanged = Notification.Name("ghosttyTmuxStateChanged")
static let browserSearchFocus = Notification.Name("browserSearchFocus")
}

Expand Down
11 changes: 11 additions & 0 deletions Sources/Panels/TerminalPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ final class TerminalPanel: Panel, ObservableObject {

@Published private(set) var tmuxLayoutReport: TmuxPaneLayoutReport?

/// Whether the terminal is in tmux control mode (tmux -CC)
@Published private(set) var tmuxActive: Bool = false

/// Search state for find functionality
@Published var searchState: TerminalSurface.SearchState? {
didSet {
Expand Down Expand Up @@ -84,6 +87,14 @@ final class TerminalPanel: Panel, ObservableObject {
}
}
.store(in: &cancellables)

// Subscribe to tmux control mode state changes
surface.$tmuxActive
.removeDuplicates()
.sink { [weak self] active in
self?.tmuxActive = active
}
.store(in: &cancellables)
}

/// Create a new terminal panel with a fresh surface
Expand Down
13 changes: 13 additions & 0 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5539,6 +5539,9 @@ final class Workspace: Identifiable, ObservableObject {
@Published private(set) var tmuxWorkspaceFlashPanelId: UUID?
@Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason?
@Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0
/// Whether any terminal panel in this workspace is in tmux control mode.
@Published private(set) var tmuxControlModeActive: Bool = false
private var tmuxControlModeSubscription: AnyCancellable?
Comment on lines +5542 to +5544

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's find and inspect the relevant lines in Workspace.swift
wc -l Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 89


🏁 Script executed:

# Read the section around lines 5542-5544 to see the property definition
sed -n '5535,5550p' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 1191


🏁 Script executed:

# Read the section around lines 6050-6058 to see the subscription setup
sed -n '6040,6070p' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 1351


🏁 Script executed:

# Search for the entire configureTerminalPanel method to understand the subscription logic
rg -A 30 'func configureTerminalPanel' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 1406


🏁 Script executed:

# Look for panel removal/teardown paths to see if subscriptions are cleaned up
rg -B 3 -A 5 'panelSubscriptions.*removeValue|panel.*deinit|removeTerminalPanel|panelWasRemoved' Sources/Workspace.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 2044


🏁 Script executed:

# Check if Workspace supports multiple terminals by looking at panel management
rg -A 3 'var panels|var panelSubscriptions' Sources/Workspace.swift | head -30

Repository: manaflow-ai/cmux

Length of output: 410


🏁 Script executed:

# Search for calls to configureTerminalPanel to verify it's invoked per terminal
rg 'configureTerminalPanel\(' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 404


🏁 Script executed:

# Check methods that add new terminals to the workspace
rg -B 5 -A 10 'func newTerminalSplit|func newTerminalSurface|func createReplacement' Sources/Workspace.swift | head -80

Repository: manaflow-ai/cmux

Length of output: 2540


🏁 Script executed:

# Verify the exact flow: when a terminal is added, is configureTerminalPanel called?
rg -B 3 -A 3 'configureTerminalPanel.*terminalPanel' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 842


🏁 Script executed:

# Check if there are any other tmux-related subscriptions or if this is the only place
rg 'tmuxControlMode|tmuxActive' Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 345


Aggregate tmux state across all terminal panels, not just the last one.

tmuxControlModeSubscription gets overwritten on every configureTerminalPanel(_:) call (which occurs once per added terminal), so this workspace ends up tracking only the most recently configured terminal. In a multi-terminal workspace, that breaks the documented "any terminal panel in this workspace" behavior and leaves earlier panel subscriptions to be garbage-collected without contributing further updates. Additionally, panel removal paths do not clean up this subscription, unlike panelSubscriptions. Store one subscription per terminal (as a dictionary), and recompute the workspace flag from the aggregate state of all live terminals instead of assigning a single panel's value.

🐛 Suggested direction
-    private var tmuxControlModeSubscription: AnyCancellable?
+    private var tmuxControlModeSubscriptions: [UUID: AnyCancellable] = [:]
+
+    private func recomputeTmuxControlModeActive() {
+        tmuxControlModeActive = panels.values.contains { ($0 as? TerminalPanel)?.tmuxActive == true }
+    }
-        let subscription = terminalPanel.$tmuxActive
+        tmuxControlModeSubscriptions[terminalPanel.id] = terminalPanel.$tmuxActive
             .removeDuplicates()
             .receive(on: DispatchQueue.main)
-            .sink { [weak self] active in
-                self?.tmuxControlModeActive = active
+            .sink { [weak self] _ in
+                self?.recomputeTmuxControlModeActive()
             }
-        // Store the subscription (replacing any prior one)
-        tmuxControlModeSubscription = subscription
+        recomputeTmuxControlModeActive()

Also remove the terminal's cancellable and call recomputeTmuxControlModeActive() from the existing panel teardown paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 5542 - 5544, The workspace currently
overwrites tmuxControlModeSubscription each time configureTerminalPanel(_:) is
called, so tmuxControlModeActive only reflects the last configured terminal;
instead change tmuxControlModeSubscription from a single AnyCancellable? to a
dictionary keyed by terminal identifier (e.g. [TerminalPanelID: AnyCancellable])
and, when configuring a terminal in configureTerminalPanel(_:), store that
terminal's cancellable into the dictionary; on terminal teardown/remove (the
same places that update panelSubscriptions) remove and cancel that terminal's
cancellable and then call recomputeTmuxControlModeActive(); implement
recomputeTmuxControlModeActive() to set the `@Published` tmuxControlModeActive by
OR-ing the tmux control mode state across all live terminals (reading each
terminal's current state or the latest published value), ensuring
panelSubscriptions logic remains unchanged except for also cleaning up the new
per-terminal cancellable.

private var manualUnreadMarkedAt: [UUID: Date] = [:]
nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2
nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2
Expand Down Expand Up @@ -6043,6 +6046,16 @@ final class Workspace: Identifiable, ObservableObject {
guard let self, let terminalPanel else { return }
self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason)
}

// Subscribe to tmux control mode state changes
let subscription = terminalPanel.$tmuxActive
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] active in
self?.tmuxControlModeActive = active

@cubic-dev-ai cubic-dev-ai Bot Apr 1, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: tmuxControlModeActive only reflects the last configured TerminalPanel. It can flip to false while another panel is still in control mode and won’t update when earlier panels change. Track tmuxActive per panel and derive any across them instead of overwriting a single subscription.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 6055:

<comment>tmuxControlModeActive only reflects the last configured TerminalPanel. It can flip to false while another panel is still in control mode and won’t update when earlier panels change. Track tmuxActive per panel and derive `any` across them instead of overwriting a single subscription.</comment>

<file context>
@@ -6043,6 +6046,16 @@ final class Workspace: Identifiable, ObservableObject {
+            .removeDuplicates()
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] active in
+                self?.tmuxControlModeActive = active
+            }
+        // Store the subscription (replacing any prior one)
</file context>
Fix with Cubic

}
// Store the subscription (replacing any prior one)
tmuxControlModeSubscription = subscription

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track tmux subscriptions per terminal panel

configureTerminalPanel replaces tmuxControlModeSubscription every time a terminal panel is created, so the workspace only observes the most recently configured terminal's tmuxActive state. In a workspace with multiple terminals, tmuxControlModeActive can flip to false when the last-subscribed panel exits tmux even if another panel is still in tmux control mode, which contradicts the property’s “any terminal panel” contract and will produce incorrect workspace-level state.

Useful? React with 👍 / 👎.

Comment on lines +6050 to +6058

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Single subscription overwrites multi-panel tmux state

tmuxControlModeSubscription is a single AnyCancellable?. Every call to configureTerminalPanel cancels the previous subscription and starts a fresh one on the newly configured panel. Because @Published emits its current value immediately upon subscription, subscribing to any new panel that starts with tmuxActive = false will immediately set tmuxControlModeActive = false, clobbering the true that was correctly set by an older panel still inside tmux control mode.

Concrete failure scenario with panels A and B in the same workspace:

  1. Panel A enters tmux → tmuxControlModeActive = true
  2. A new panel B is configured → subscription switches to B
  3. B emits its initial false → tmuxControlModeActive = false ← wrong; A is still in tmux mode
  4. A exits tmux → no subscriber left, the flag is never corrected

The property's doc-comment says "Whether any terminal panel in this workspace is in tmux control mode", but the implementation only tracks the last configured panel.

Fix: track subscriptions per-panel in the existing panelSubscriptions: [UUID: AnyCancellable] dictionary (used for browser/markdown panels), combining their individual tmuxActive states with a derived publisher, or maintain a Set<UUID> of active-tmux panels and compute the boolean from its emptiness.

Comment on lines +6057 to +6058

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset tmux workspace state when tracked panel disappears

configureTerminalPanel stores tmux observation in a single tmuxControlModeSubscription, but panel teardown only removes entries from panelSubscriptions and never cancels/recomputes this tmux subscription. If the currently tracked terminal is closed while tmuxActive is true (for example, closing a pane during tmux -CC), the workspace can stay stuck in tmux-control-mode state because no later false update is guaranteed from a removed panel. Please track subscriptions per terminal panel (or recompute from live panels on close) so tmuxControlModeActive reflects current workspace state.

Useful? React with 👍 / 👎.

}

private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) {
Expand Down
29 changes: 29 additions & 0 deletions ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,20 @@ typedef enum {
GHOSTTY_READONLY_ON,
} ghostty_action_readonly_e;

// apprt.action.TmuxState
typedef enum {
GHOSTTY_TMUX_STATE_ENTERED,
GHOSTTY_TMUX_STATE_EXITED,
GHOSTTY_TMUX_STATE_WINDOWS_CHANGED,
} ghostty_action_tmux_state_e;

// Tmux window info returned by ghostty_surface_tmux_window_info
typedef struct {
uintptr_t id;
uintptr_t width;
uintptr_t height;
} ghostty_tmux_window_s;

// apprt.action.DesktopNotification.C
typedef struct {
const char* title;
Expand Down Expand Up @@ -910,6 +924,8 @@ typedef enum {
GHOSTTY_ACTION_SEARCH_TOTAL,
GHOSTTY_ACTION_SEARCH_SELECTED,
GHOSTTY_ACTION_READONLY,
GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD,
GHOSTTY_ACTION_TMUX_STATE,
} ghostty_action_tag_e;

typedef union {
Expand Down Expand Up @@ -950,6 +966,7 @@ typedef union {
ghostty_action_search_total_s search_total;
ghostty_action_search_selected_s search_selected;
ghostty_action_readonly_e readonly;
ghostty_action_tmux_state_e tmux_state;
} ghostty_action_u;

typedef struct {
Expand Down Expand Up @@ -1154,6 +1171,18 @@ void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*);
bool ghostty_inspector_metal_shutdown(ghostty_inspector_t);
#endif

// Tmux control mode API
bool ghostty_surface_tmux_active(ghostty_surface_t);
uintptr_t ghostty_surface_tmux_window_count(ghostty_surface_t);
uintptr_t ghostty_surface_tmux_window_info(ghostty_surface_t,
ghostty_tmux_window_s*,
uintptr_t);
uintptr_t ghostty_surface_tmux_pane_count(ghostty_surface_t);
uintptr_t ghostty_surface_tmux_pane_ids(ghostty_surface_t,
uintptr_t*,
uintptr_t);
bool ghostty_surface_tmux_set_active_pane(ghostty_surface_t, uintptr_t);

// APIs I'd like to get rid of eventually but are still needed for now.
// Don't use these unless you know what you're doing.
void ghostty_set_window_background_blur(ghostty_app_t, void*);
Expand Down
1 change: 1 addition & 0 deletions scripts/ghosttykit-checksums.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606b
bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 6b83b66768e8bba871a3753ae8ffbaabd03370b306c429cd86c9cdcc8db82589
41e796064e89eacabdf3a6729475e250a5518e7a 135302bbdf3e83b200f0165ff2a32cdf13017219e6c8ffca17b672edfbfae395
f9030b5c5232db69ba8625bb53d51ce735b80d51 6c439d731d97bd35a3289f54478af3ac01e30ba74ec2672490e0c98f95262b55
4c170d120d5bc87d039fab75c1f2cfeed1951108 56e1644d1e9020edd2c41e326df03d00387538371fc5498a4febcf9d17b4d7de

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin checksum for the updated ghostty submodule SHA

scripts/download-prebuilt-ghosttykit.sh looks up the checksum by the current ghostty submodule HEAD, but this commit moves the gitlink to f7bf18e56f7ae2fa3b02a108b34d6f17fd12226b while adding a checksum entry for 4c170d120d5bc87d039fab75c1f2cfeed1951108. In this state, ./scripts/setup.sh (or direct GhosttyKit download) will fail with “Missing pinned GhosttyKit checksum for ghostty …”, blocking dependency setup and builds for this revision.

Useful? React with 👍 / 👎.