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
4 changes: 2 additions & 2 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
14225 Sources/TerminalController.swift
12646 Sources/Workspace.swift
12115 cmuxTests/AppDelegateShortcutRoutingTests.swift
11731 Sources/GhosttyTerminalView.swift
11747 Sources/GhosttyTerminalView.swift
11388 Sources/Panels/BrowserPanel.swift
9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
7931 Sources/Panels/BrowserPanelView.swift
7356 cmuxTests/WorkspaceUnitTests.swift
7221 cmuxTests/WorkspaceRemoteConnectionTests.swift
6317 cmuxTests/SessionPersistenceTests.swift
6223 cmuxTests/GhosttyConfigTests.swift
6154 Sources/TabManager.swift
6187 Sources/TabManager.swift
6153 CLI/cmux_open.swift
6074 Sources/TextBoxInput.swift
5925 cmuxTests/TerminalAndGhosttyTests.swift
Expand Down
18 changes: 17 additions & 1 deletion Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2877,8 +2877,17 @@ class GhosttyApp {
.flatMap { String(cString: $0) } ?? ""
if let tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id {
let change = GhosttyTitleChange(tabId: tabId, surfaceId: surfaceId, title: title)
DispatchQueue.main.async {
// Collapse frame-by-frame spinner titles (pnpm, cargo, codex,
// …) to a stable form and skip posting when nothing
// meaningful changed. This stops redundant title posts from
// flooding the panel-title coalescer and the toolbar
// command-text updater, which starved sidebar hit-testing
// (issue #6291).
let stable = TabManager.stableTerminalPanelTitle(title)
guard surfaceView.lastPublishedTerminalTitle != stable else { return }
surfaceView.lastPublishedTerminalTitle = stable
let change = GhosttyTitleChange(tabId: tabId, surfaceId: surfaceId, title: stable)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
NotificationCenter.default.post(
name: .ghosttyDidSetTitle,
object: surfaceView,
Expand Down Expand Up @@ -3419,6 +3428,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}

weak var terminalSurface: TerminalSurface?
/// The most recent terminal title posted through `.ghosttyDidSetTitle`,
/// already collapsed to its stable (spinner-stripped) form. Used to drop
/// frame-by-frame spinner title updates at the source so they never reach
/// the workspace panel-title coalescer or the toolbar command-text updater
/// (issue #6291). Main-thread only (mutated from the title action handler's
/// main-queue hop).
var lastPublishedTerminalTitle: String = ""

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 Stale lastPublishedTerminalTitle on surface replacement

lastPublishedTerminalTitle is initialised to "" and is never cleared when terminalSurface is replaced (e.g. when the terminal process exits and a new shell spawns in the same panel). The property is keyed on the GhosttyNSView instance, not on the (tabId, surfaceId) pair, so if the view is reused with a fresh TerminalSurface the dedup guard will silently suppress the new session's first title notification whenever its stable form matches the previous session's last published title. Clearing lastPublishedTerminalTitle to "" when terminalSurface is set to nil (in the detach path near line 7058) would close the window.

var scrollbar: GhosttyScrollbar?
/// Pending scrollbar value written from the action callback thread;
/// read and cleared on the main thread by `flushPendingScrollbar()`.
Expand Down
37 changes: 35 additions & 2 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3279,16 +3279,49 @@ class TabManager: ObservableObject {
}


/// Braille spinner glyphs that CLI tools (pnpm, npm, cargo, codex, ora-based
/// Node spinners, …) cycle through frame-by-frame in the terminal title.
/// Stripping a leading glyph from this set collapses consecutive animation
/// frames to one stable title so they can be deduped (issue #6291). Limited
/// to braille glyphs because they effectively never lead a real title — so
/// stripping them can't corrupt a legitimate title.
nonisolated static let terminalTitleSpinnerCharacters: Set<Character> = [
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
"⠉", "⠁", "⠈", "⠐", "⠠", "⢀", "⡀", "⠄", "⠂",
"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷",
]

/// Collapses a raw terminal title to a stable form by stripping any run of
/// leading spinner glyphs plus the whitespace that follows them, then
/// whitespace-trimming. Frame N and frame N+1 of a spinner-updating title
/// that differ only in the leading glyph map to the same string, which lets
/// callers drop redundant updates (issue #6291). Pure and `nonisolated` so
/// both the off-main title action handler and tests can call it.
nonisolated static func stableTerminalPanelTitle(_ rawTitle: String) -> String {
var remainder = Substring(rawTitle.trimmingCharacters(in: .whitespacesAndNewlines))
while let first = remainder.first, terminalTitleSpinnerCharacters.contains(first) {
remainder = remainder.dropFirst()
while let next = remainder.first, next == " " || next == "\t" {
remainder = remainder.dropFirst()
}
}
return remainder.trimmingCharacters(in: .whitespacesAndNewlines)
}

private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = Self.stableTerminalPanelTitle(title)
guard !trimmed.isEmpty else { return }
let key = PanelTitleUpdateKey(tabId: tabId, panelId: panelId)
// Drop redundant spinner-frame updates: the same stable title is already
// queued for this panel, so re-flushing it would only thrash the main
// thread (issue #6291).
if pendingPanelTitleUpdates[key] == trimmed { return }
#if DEBUG
cmuxDebugLog(
"workspace.title.enqueue workspace=\(Self.debugShortWorkspaceId(tabId)) " +
"panel=\(panelId.uuidString.prefix(5)) title=\"\(Self.debugTitlePreview(trimmed))\""
)
#endif
let key = PanelTitleUpdateKey(tabId: tabId, panelId: panelId)
pendingPanelTitleUpdates[key] = trimmed
panelTitleUpdateCoalescer.signal { [weak self] in
self?.flushPendingPanelTitleUpdates()
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@
604500100000000000000007 /* TabManager+WindowTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604500100000000000000008 /* TabManager+WindowTitle.swift */; };
A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; };
2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; };
6291A0BCDEF0123456789AB0 /* TabManagerTerminalTitleCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6291A0BCDEF0123456789AB1 /* TabManagerTerminalTitleCollapseTests.swift */; };
B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */; };
C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A507000000000000000001 /* TaskManagerResourcesTests.swift */; };
C7A503000000000000000002 /* TaskManagerSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A503000000000000000001 /* TaskManagerSnapshot.swift */; };
Expand Down Expand Up @@ -1718,6 +1719,7 @@
604500100000000000000008 /* TabManager+WindowTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WindowTitle.swift"; sourceTree = "<group>"; };
A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = "<group>"; };
6291A0BCDEF0123456789AB1 /* TabManagerTerminalTitleCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerTerminalTitleCollapseTests.swift; sourceTree = "<group>"; };
42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = "<group>"; };
C7A507000000000000000001 /* TaskManagerResourcesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerResourcesTests.swift; sourceTree = "<group>"; };
C7A503000000000000000001 /* TaskManagerSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerSnapshot.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2851,6 +2853,7 @@
6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */,
EF7347934AB1BD387686EE43 /* WorkspaceActionDispatcherTests.swift */,
E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */,
6291A0BCDEF0123456789AB1 /* TabManagerTerminalTitleCollapseTests.swift */,
F92AB6D1CB714189A8F167E5 /* SetAutoTitleSocketTests.swift */,
7752D68A758642EFA282C208 /* AutoNamingEngineTests.swift */,
70034F4D644C4896B8B925F3 /* AutoNamingCodexAdapterTests.swift */,
Expand Down Expand Up @@ -4286,6 +4289,7 @@
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */,
2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */,
6291A0BCDEF0123456789AB0 /* TabManagerTerminalTitleCollapseTests.swift in Sources */,
B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */,
C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */,
C7A507000000000000004529 /* TaskManagerViewSnapshotBoundaryTests.swift in Sources */,
Expand Down
113 changes: 113 additions & 0 deletions cmuxTests/TabManagerTerminalTitleCollapseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Foundation
import Testing

#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif

/// Regression coverage for the sidebar-freeze caused by CLI tools that update
/// the terminal title on every animation frame with a leading braille spinner
/// glyph (pnpm, npm, cargo, codex, …). The frame-by-frame title churn used to
/// flood the panel-title coalescer and toolbar command-text updater and starve
/// sidebar hit-testing. The fix collapses such titles to a stable form so the
/// redundant frames dedupe instead of re-driving the main thread (issue #6291).
@Suite struct TabManagerTerminalTitleCollapseTests {

/// A sequence of spinner frames whose only difference is the leading braille
/// glyph must collapse to a single stable panel title, so consecutive frames
/// are deduplicated rather than each driving a panel-title mutation.
@Test func testCodexSpinnerTerminalTitlesCollapseToStablePanelTitle() {
let spinnerFrames = [
"⠋ codex: building",
"⠙ codex: building",
"⠹ codex: building",
"⠸ codex: building",
"⠼ codex: building",
"⠴ codex: building",
"⠦ codex: building",
"⠧ codex: building",
"⠇ codex: building",
"⠏ codex: building",
]

let stableTitles = spinnerFrames.map { TabManager.stableTerminalPanelTitle($0) }
let distinct = Set(stableTitles)

#expect(distinct == ["codex: building"])
#expect(stableTitles.allSatisfy { $0 == "codex: building" })
}

/// A title with no leading spinner glyph is only whitespace-trimmed, never
/// otherwise mutated.
@Test func testNonSpinnerTitleIsPreservedExceptForTrimming() {
#expect(TabManager.stableTerminalPanelTitle(" pnpm install ") == "pnpm install")
#expect(TabManager.stableTerminalPanelTitle("zsh") == "zsh")
// A leading hyphen/letter is real title text and must survive intact.
#expect(TabManager.stableTerminalPanelTitle("- my project") == "- my project")
}

/// Frames that differ in their trailing (non-spinner) text remain distinct,
/// so genuine title changes still propagate.
@Test func testDistinctTrailingTextRemainsDistinct() {
#expect(
TabManager.stableTerminalPanelTitle("⠋ pnpm install")
!= TabManager.stableTerminalPanelTitle("⠙ pnpm build")
)
}

/// Behavior-level coverage of the #6291 repro: a long stream of spinner
/// frames must result in only ONE effective title update once dedup runs.
///
/// Both dedup layers key off the same value and the same equality: the
/// source-level guard (`GhosttyNSView.lastPublishedTerminalTitle != stable`)
/// and the coalescer guard (`pendingPanelTitleUpdates[key] == trimmed`) each
/// compare the *collapsed* title against the last one and drop it when
/// unchanged. This replays that shared predicate over the real
/// `stableTerminalPanelTitle` to assert the flood collapses to a single
/// publish — the property that stops sidebar hit-testing from being starved.
/// (The guards themselves are `private`/AppKit-bound, so this exercises the
/// production collapse function plus the exact dedup rule rather than the
/// UI wiring.)
@Test func testSpinnerStreamYieldsSingleEffectiveTitleUpdate() {
// 120 frames cycling every braille glyph, same trailing text — exactly
// the pnpm/cargo/codex churn that froze the sidebar.
let glyphs = Array(TabManager.terminalTitleSpinnerCharacters)
let frames = (0..<120).map { "\(glyphs[$0 % glyphs.count]) pnpm install" }

var publishedCount = 0
var lastPublished: String? = nil
for frame in frames {
let stable = TabManager.stableTerminalPanelTitle(frame)
guard lastPublished != stable else { continue }
lastPublished = stable
publishedCount += 1
}

#expect(publishedCount == 1)
#expect(lastPublished == "pnpm install")
}

/// A genuine title change after a spinner stream still publishes: dedup must
/// suppress only redundant frames, never a real transition (e.g. install →
/// build → done), so the panel/toolbar keep reflecting real progress.
@Test func testGenuineTransitionsStillPublishAfterDedup() {
let stream = [
"⠋ pnpm install", "⠙ pnpm install", "⠹ pnpm install", // frame churn
"⠋ pnpm build", "⠙ pnpm build", // real change → 1
"done", // real change → 1
]

var published: [String] = []
var lastPublished: String? = nil
for frame in stream {
let stable = TabManager.stableTerminalPanelTitle(frame)
guard lastPublished != stable else { continue }
lastPublished = stable
published.append(stable)
}

#expect(published == ["pnpm install", "pnpm build", "done"])
}
}
Loading