diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index e1047bae58b3..36b0bfbb5b67 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -7,7 +7,7 @@ 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 @@ -15,7 +15,7 @@ 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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 05da87aa85eb..60f68720a5fa 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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) NotificationCenter.default.post( name: .ghosttyDidSetTitle, object: surfaceView, @@ -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 = "" var scrollbar: GhosttyScrollbar? /// Pending scrollbar value written from the action callback thread; /// read and cleared on the main thread by `flushPendingScrollbar()`. diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index bd50044d32f6..5f9fc43230f0 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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 = [ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", + "⠉", "⠁", "⠈", "⠐", "⠠", "⢀", "⡀", "⠄", "⠂", + "⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷", + ] + + /// 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() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 9643833a3545..f80fc2c9e90d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -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 */; }; @@ -1718,6 +1719,7 @@ 604500100000000000000008 /* TabManager+WindowTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WindowTitle.swift"; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; + 6291A0BCDEF0123456789AB1 /* TabManagerTerminalTitleCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerTerminalTitleCollapseTests.swift; sourceTree = ""; }; 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = ""; }; C7A507000000000000000001 /* TaskManagerResourcesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerResourcesTests.swift; sourceTree = ""; }; C7A503000000000000000001 /* TaskManagerSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerSnapshot.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, diff --git a/cmuxTests/TabManagerTerminalTitleCollapseTests.swift b/cmuxTests/TabManagerTerminalTitleCollapseTests.swift new file mode 100644 index 000000000000..2fda779d6e91 --- /dev/null +++ b/cmuxTests/TabManagerTerminalTitleCollapseTests.swift @@ -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"]) + } +}