diff --git a/docs/components/terminal.md b/docs/components/terminal.md index 22f5e6e8..4982a125 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -68,6 +68,12 @@ A tab's displayed title is, in order of precedence: 2. the **live shell title** the running program emits (OSC 2), else 3. an auto-generated default like `project 1`, `project 2`. +Rapid live-title animation is coalesced per tab to at most one visible update +per second so one spinner frame does not rebuild the entire tab bar. The newest +withheld title is applied at the end of that interval even when no agent is +running, so a live title may visibly lag by up to one second but its final value +is not left behind. + The Run Script tab is labeled **RUN SCRIPT** and is **title-locked** for its lifetime. Prowl also "learns" your shell's idle prompt so it doesn't mistake it for a meaningful title. diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index 4a1757ae..c3565224 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -34,6 +34,14 @@ final class TerminalTabManager { /// The most recent title held back by coalescing. A spinner that stops leaves /// no further change to carry it, so `flushPendingTitles` lands it instead. @ObservationIgnored private var pendingLiveTitles: [TerminalTabID: String] = [:] + @ObservationIgnored private let titleFlushClock: any Clock + @ObservationIgnored private var pendingTitleFlushTask: Task? + @ObservationIgnored private var scheduledPendingTitleFlushDate: Date? + @ObservationIgnored var onCoalescedTitlesFlushed: (([TerminalTabID]) -> Void)? + + init(titleFlushClock: any Clock = ContinuousClock()) { + self.titleFlushClock = titleFlushClock + } /// Creates a tab next to the current selection. With `select: false` the /// selection is left untouched (background creation, e.g. a headless handoff @@ -71,8 +79,14 @@ final class TerminalTabManager { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return false } guard !tabs[index].isTitleLocked else { return false } // A TUI re-emits the same title constantly; skip the no-op write so it - // doesn't invalidate the tab bar while an agent streams output. - guard tabs[index].title != title else { return false } + // doesn't invalidate the tab bar while an agent streams output. The latest + // title still supersedes a held frame: A → B (held) → A must not flush B. + guard tabs[index].title != title else { + if pendingLiveTitles.removeValue(forKey: id) != nil { + scheduleNextPendingTitleFlush(referenceDate: now) + } + return false + } // A changed title arriving inside the interval is almost always the next // frame of an animation. Hold it rather than rebuilding the tab bar for it; // the newest one wins, so nothing queues up. @@ -80,23 +94,26 @@ final class TerminalTabManager { now.timeIntervalSince(lastWriteAt) < Self.liveTitleCoalescingInterval { pendingLiveTitles[id] = title + scheduleNextPendingTitleFlush(referenceDate: now) return false } - return writeLiveTitle(title, toTabAt: index, id: id, now: now) + let changed = writeLiveTitle(title, toTabAt: index, id: id, now: now) + scheduleNextPendingTitleFlush(referenceDate: now) + return changed } /// Lands any held-back title whose interval has elapsed. Returns the tabs whose /// visible `displayTitle` moved, so callers can refresh derived UI exactly as /// they would after `updateTitle`. /// - /// Driven by the agent-detection poll rather than a timer: the only way a - /// pending title is left stranded is that the writes stopped, and the poll is - /// already running for precisely the panes that animate. + /// A clock-driven trailing task calls this at the earliest pending deadline, + /// independently of agent detection, so non-agent programs cannot strand a + /// final title after the detection schedule goes cold. @discardableResult func flushPendingTitles(now: Date = Date()) -> [TerminalTabID] { guard !pendingLiveTitles.isEmpty else { return [] } var changed: [TerminalTabID] = [] - for (id, title) in pendingLiveTitles { + for (id, title) in Array(pendingLiveTitles) { guard let lastWriteAt = lastLiveTitleWriteAt[id], now.timeIntervalSince(lastWriteAt) >= Self.liveTitleCoalescingInterval else { continue } @@ -109,6 +126,7 @@ final class TerminalTabManager { changed.append(id) } } + scheduleNextPendingTitleFlush(referenceDate: now) return changed } @@ -129,6 +147,39 @@ final class TerminalTabManager { let liveIDs = Set(tabs.map(\.id)) lastLiveTitleWriteAt = lastLiveTitleWriteAt.filter { liveIDs.contains($0.key) } pendingLiveTitles = pendingLiveTitles.filter { liveIDs.contains($0.key) } + scheduleNextPendingTitleFlush(referenceDate: Date()) + } + + private func scheduleNextPendingTitleFlush(referenceDate: Date) { + let nextFlushDate = pendingLiveTitles.keys.compactMap { id in + lastLiveTitleWriteAt[id]?.addingTimeInterval(Self.liveTitleCoalescingInterval) + }.min() + guard let nextFlushDate else { + pendingTitleFlushTask?.cancel() + pendingTitleFlushTask = nil + scheduledPendingTitleFlushDate = nil + return + } + guard nextFlushDate != scheduledPendingTitleFlushDate else { return } + + pendingTitleFlushTask?.cancel() + scheduledPendingTitleFlushDate = nextFlushDate + let delay = max(0, nextFlushDate.timeIntervalSince(referenceDate)) + let sleep = titleFlushClock.anchoredSleep(for: .seconds(delay)) + pendingTitleFlushTask = Task { @MainActor [weak self] in + do { + try await sleep() + } catch { + return + } + guard let self, self.scheduledPendingTitleFlushDate == nextFlushDate else { return } + self.pendingTitleFlushTask = nil + self.scheduledPendingTitleFlushDate = nil + let changed = self.flushPendingTitles(now: nextFlushDate) + if !changed.isEmpty { + self.onCoalescedTitlesFlushed?(changed) + } + } } /// Every tab the coalescing bookkeeping still holds an entry for. diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 2ae73c40..ec952440 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -26,7 +26,6 @@ extension WorktreeTerminalState { guard let self, let view, self.surfaces[view.id] != nil else { return } let hasAgent = await self.detectAgentState(for: view, tabId: tabId) let now = Date() - self.flushCoalescedTabTitles(now: now) let schedule = self.agentDetectionSchedules[view.id] ?? .cold self.agentDetectionSchedules[view.id] = hasAgent ? schedule.observedAgent(now: now) : schedule.observedNoAgent(now: now) @@ -270,10 +269,8 @@ extension WorktreeTerminalState { /// entries that follow them — the same refresh a title written directly through /// `updateTitle` triggers, so the two paths cannot drift. /// - /// Driven by the detection poll: a spinner that stops animating leaves no further - /// title change to carry its last frame, and the poll is already running for - /// exactly the panes that animate. Split out of that `Task` loop because the loop - /// offers no synchronous seam a test can drive. + /// The manager's clock-driven trailing flush uses the same refresh path. This + /// synchronous seam lets callers and tests force a flush at an explicit date. @discardableResult func flushCoalescedTabTitles(now: Date = Date()) -> [TerminalTabID] { let flushed = tabManager.flushPendingTitles(now: now) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index ea133b34..9b1eb0f9 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -215,18 +215,24 @@ final class WorktreeTerminalState { worktree: Worktree, runSetupScript: Bool = false, defaultFontSize: Float32? = nil, - targetHandleRegistry: TerminalTargetHandleRegistry? = nil + targetHandleRegistry: TerminalTargetHandleRegistry? = nil, + titleFlushClock: any Clock = ContinuousClock() ) { self.runtime = runtime self.worktree = worktree self.targetHandleRegistry = targetHandleRegistry ?? TerminalTargetHandleRegistry() self.pendingSetupScript = runSetupScript self.defaultFontSize = defaultFontSize - self.tabManager = TerminalTabManager() + self.tabManager = TerminalTabManager(titleFlushClock: titleFlushClock) _repositorySettings = SharedReader( wrappedValue: RepositorySettings.default, .repositorySettings(worktree.repositoryRootURL) ) + self.tabManager.onCoalescedTitlesFlushed = { [weak self] tabIDs in + for tabID in tabIDs { + self?.refreshAgentEntriesForTitleChange(in: tabID) + } + } } var worktreeID: Worktree.ID { worktree.id } diff --git a/supacode/Support/Debouncer.swift b/supacode/Support/Debouncer.swift index b27a4386..fab0bc7c 100644 --- a/supacode/Support/Debouncer.swift +++ b/supacode/Support/Debouncer.swift @@ -51,7 +51,7 @@ final class Debouncer { /// `TestClock`, let the test advance past a deadline that was never armed, /// hanging the sleep forever (the CI-only `DebouncerTests` flake). extension Clock where Duration == Swift.Duration { - fileprivate func anchoredSleep(for interval: Duration) -> @Sendable () async throws -> Void { + func anchoredSleep(for interval: Duration) -> @Sendable () async throws -> Void { let deadline = now.advanced(by: interval) return { try await self.sleep(until: deadline, tolerance: nil) } } diff --git a/supacodeTests/TabTitleFlushRefreshTests.swift b/supacodeTests/TabTitleFlushRefreshTests.swift index f41fba4d..15cc8333 100644 --- a/supacodeTests/TabTitleFlushRefreshTests.swift +++ b/supacodeTests/TabTitleFlushRefreshTests.swift @@ -1,4 +1,5 @@ import AppKit +import Clocks import Foundation import GhosttyKit import Testing @@ -64,13 +65,36 @@ struct TabTitleFlushRefreshTests { #expect(fixture.state.tabManager.tabs.first?.title == "⠋ building") } + @Test func aWithheldTitleFlushesWithoutAnAgentDetectionTask() async { + let clock = TestClock() + let fixture = makeFixture(titleFlushClock: clock) + var received: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { received.append($0) } + + _ = fixture.state.tabManager.updateTitle(fixture.tabId, title: "A", now: start) + _ = fixture.state.tabManager.updateTitle( + fixture.tabId, + title: "B", + now: start.addingTimeInterval(0.2) + ) + #expect(fixture.state.agentDetectionTasks.isEmpty) + + await clock.advance(by: .seconds(1)) + for _ in 0..<10 { await Task.yield() } + + #expect(fixture.state.tabManager.tabs.first?.title == "B") + #expect(received.map(\.paneTitle) == ["B"]) + } + private struct Fixture { let state: WorktreeTerminalState let tabId: TerminalTabID let pane: GhosttySurfaceView } - private func makeFixture() -> Fixture { + private func makeFixture( + titleFlushClock: any Clock = ContinuousClock() + ) -> Fixture { let state = WorktreeTerminalState( runtime: GhosttyRuntime(), worktree: Worktree( @@ -79,7 +103,8 @@ struct TabTitleFlushRefreshTests { detail: "", workingDirectory: URL(fileURLWithPath: "/tmp/repo/worktree"), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") - ) + ), + titleFlushClock: titleFlushClock ) let pane = GhosttySurfaceView( runtime: state.runtime, diff --git a/supacodeTests/TerminalTabTitleCoalescingTests.swift b/supacodeTests/TerminalTabTitleCoalescingTests.swift index c8eca9f9..537b3d34 100644 --- a/supacodeTests/TerminalTabTitleCoalescingTests.swift +++ b/supacodeTests/TerminalTabTitleCoalescingTests.swift @@ -1,3 +1,4 @@ +import Clocks import Foundation import Testing @@ -91,6 +92,16 @@ struct TerminalTabTitleCoalescingTests { #expect(title(of: manager, id) == "finished") } + @Test func aSuppressedTitleIsDiscardedWhenTheLatestTitleRevertsToTheVisibleValue() { + let (manager, id) = makeManager() + _ = manager.updateTitle(id, title: "A", now: start) + _ = manager.updateTitle(id, title: "B", now: start.addingTimeInterval(0.2)) + + #expect(manager.updateTitle(id, title: "A", now: start.addingTimeInterval(0.4)) == false) + #expect(manager.flushPendingTitles(now: start.addingTimeInterval(1.5)).isEmpty) + #expect(title(of: manager, id) == "A") + } + @Test func coalescingIsPerTabNotGlobal() { let manager = TerminalTabManager() let first = manager.createTab(title: "first", icon: nil) @@ -104,6 +115,33 @@ struct TerminalTabTitleCoalescingTests { #expect(title(of: manager, second) == "⠋ two") } + @Test func automaticFlushRearmsForTheNextTabsLaterDeadline() async { + let clock = TestClock() + let manager = TerminalTabManager(titleFlushClock: clock) + let first = manager.createTab(title: "first", icon: nil) + let second = manager.createTab(title: "second", icon: nil) + var flushed: [[TerminalTabID]] = [] + manager.onCoalescedTitlesFlushed = { flushed.append($0) } + + _ = manager.updateTitle(first, title: "first A", now: start) + _ = manager.updateTitle(first, title: "first B", now: start.addingTimeInterval(0.1)) + _ = manager.updateTitle(second, title: "second A", now: start.addingTimeInterval(0.4)) + _ = manager.updateTitle(second, title: "second B", now: start.addingTimeInterval(0.5)) + + await clock.advance(by: .seconds(0.9)) + for _ in 0..<10 { await Task.yield() } + + #expect(title(of: manager, first) == "first B") + #expect(title(of: manager, second) == "second A") + #expect(flushed == [[first]]) + + await clock.advance(by: .seconds(0.4)) + for _ in 0..<10 { await Task.yield() } + + #expect(title(of: manager, second) == "second B") + #expect(flushed == [[first], [second]]) + } + @Test func aLockedTitleIsNeverWrittenOrHeld() { let manager = TerminalTabManager() let id = manager.createTab(title: "pinned", icon: nil, isTitleLocked: true)