diff --git a/AGENTS.md b/AGENTS.md
index e207ba402..a199c8150 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -36,7 +36,7 @@ Supacode is a macOS terminal emulator that for running multiple coding agents in
- Avoid top-level free functions. Default to `static` methods, computed properties, or instance methods on a relevant type (enum/struct/extension). Free functions pollute the module namespace, are harder to discover, and easily drift from the inline implementation a consumer ends up writing instead. If the operation is pure and stateless, make it a `static` on a caseless `enum` or the most relevant type, not a top-level `func`.
- Closure-typed focused values invalidate the AppKit menu on every body run (closures have no Equatable conformance, so SwiftUI re-publishes every time). Always wrap menu-bar action closures with `FocusedAction` and publish via `.focusedSceneAction(_:enabled:token:perform:)` / `.focusedAction(_:enabled:token:perform:)`. The wrapper dedupes on `(isEnabled, token)`, so AppKit only rebuilds the menu when something the menu actually displays changes. Token rules in `App/Models/FocusedAction.swift`: set `token` to a hashable projection of any captured state that affects behavior; leave it `nil` when the closure captures only the store / `@State` bindings. Consumers should read the action with `@FocusedValue(\.x)` and gate with `action?.isEnabled != true`, not `action == nil`.
- Sidebar rows must not fan out invalidation. Per-row state lives in `RepositoriesFeature.State.sidebarItems` so a per-leaf mutation (notification tick, agent activity, running-script update) invalidates only that leaf, not every sibling. The view renders the cached `state.sidebarStructure` (computed in the reducer's post-reduce hook), never reading `sidebarItems[id:]` from a view body; derive per-leaf data in `computeSidebarStructure(...)`, not in the view.
-- Never lift content-specific (e.g. terminal-only) mechanics or state into the layout layer. `LayoutFeature` owns topology and strip mechanics only (panes, tabs, selection, focus, zoom, rename identity); anything a specific content kind produces (agent badges, progress, script locks, busyness) lives on the content side, exposed to the strip through the content's observable `TabChrome` (`Features/Terminal/Content/TabChrome.swift`), never as layout reducer state or actions.
+- Never lift content-specific (e.g. terminal-only) mechanics or state into the layout layer. `LayoutFeature` owns topology and strip mechanics only (panes, tabs, selection, focus, zoom, rename identity); anything a specific content kind produces (agent badges, progress, script locks, busyness, reported title, pwd) lives on the content side, exposed to the strip through the content's observable `TabChrome` (`Features/Terminal/Content/TabChrome.swift`), never as layout reducer state or actions. High-frequency reported signals (title, pwd, bell) are the sharp edge of this rule: an agent TUI rewrites its title several times a second, so a reported title lands on the content's chrome and reaches the layout only as one discrete commit at teardown (`titleCommitted`), never one reducer action per report. The strip and window title read the live value off `TabChrome` (`TabTitle.resolved`), and persistence pulls it into the snapshot (`TabTitle.stored`).
## UX Standards
diff --git a/supacode/App/WindowTitle.swift b/supacode/App/WindowTitle.swift
index c2f41e769..0bf28392c 100644
--- a/supacode/App/WindowTitle.swift
+++ b/supacode/App/WindowTitle.swift
@@ -65,8 +65,10 @@ enum WindowTitle {
repository: repository,
repositories: repositories
)
+ // Through the content's chrome: reported titles never enter the reducer, so
+ // this read is what keeps the window title tracking the terminal.
let tabTitle = terminalManager.hostIfExists(for: worktreeID)?.focusedTab.flatMap { tab in
- sanitize(tab.customTitle ?? tab.title)
+ sanitize(TabTitle.resolved(for: tab, runtime: ContentRuntime.liveValue))
}
return format(repo: repoTitle, tab: tabTitle)
}
diff --git a/supacode/Clients/AppLifecycle/MemoryPressureClient.swift b/supacode/Clients/AppLifecycle/MemoryPressureClient.swift
new file mode 100644
index 000000000..f497a105b
--- /dev/null
+++ b/supacode/Clients/AppLifecycle/MemoryPressureClient.swift
@@ -0,0 +1,27 @@
+import ComposableArchitecture
+import Foundation
+
+/// Process-wide memory-pressure warnings, as a stream the hibernation policy
+/// subscribes to once. Injected so a test can drive pressure without the kernel.
+struct MemoryPressureClient {
+ var warnings: @Sendable () -> AsyncStream
+}
+
+extension MemoryPressureClient: DependencyKey {
+ static let liveValue = MemoryPressureClient(
+ warnings: {
+ AsyncStream { continuation in
+ let source = DispatchSource.makeMemoryPressureSource(
+ eventMask: [.warning, .critical],
+ queue: .main
+ )
+ source.setEventHandler { continuation.yield() }
+ continuation.onTermination = { _ in source.cancel() }
+ source.resume()
+ }
+ }
+ )
+
+ // Silent by default: only a test that opts in should see pressure.
+ static let testValue = MemoryPressureClient(warnings: { AsyncStream { $0.finish() } })
+}
diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift
index c6fc9d7f8..bac8ce02b 100644
--- a/supacode/Features/App/Reducer/AppFeature.swift
+++ b/supacode/Features/App/Reducer/AppFeature.swift
@@ -398,6 +398,7 @@ struct AppFeature {
refreshInstalledOpenActionsEffect(current: state.installedOpenActions),
.send(.repositories(.task)),
.send(.settings(.task)),
+ .send(.terminals(.task)),
.run { @MainActor send in
guard startupHotkey != nil else { return }
if !appLifecycleClient.updateGlobalHotkey(startupHotkey) {
diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift
index 0c3357b9c..4a4b4ccd3 100644
--- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift
+++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift
@@ -303,6 +303,10 @@ struct WorktreeDetailView: View {
}
} else if let selectedWorktree {
let shouldFocusTerminal = repositories.shouldFocusTerminal(for: selectedWorktree.id)
+ let pendingTerminalFocus: Worktree.ID? = shouldFocusTerminal ? selectedWorktree.id : nil
+ // No `.id` on purpose: keeping the view stable across a worktree switch
+ // lets the live surface reparent its cached wrapper instead of tearing
+ // the hosting chain down and rebuilding it at zero size.
WorktreeLayoutView(
worktree: selectedWorktree,
manager: terminalManager,
@@ -311,13 +315,13 @@ struct WorktreeDetailView: View {
forceAutoFocus: shouldFocusTerminal,
isLifecycleBusy: selectedSlice?.lifecycle.isBusy ?? false
)
- .id(selectedWorktree.id)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(.container, edges: .bottom)
- .onAppear {
- if shouldFocusTerminal {
- store.send(.repositories(.consumeTerminalFocus(selectedWorktree.id)))
- }
+ // The subtree is stable across a switch, so `onAppear` fires only once;
+ // drive the consume from the focus request itself.
+ .onChange(of: pendingTerminalFocus, initial: true) { _, target in
+ guard let target else { return }
+ store.send(.repositories(.consumeTerminalFocus(target)))
}
} else if !repositories.isInitialLoadComplete {
DetailPlaceholderView()
diff --git a/supacode/Features/Terminal/BusinessLogic/LayoutPersistence.swift b/supacode/Features/Terminal/BusinessLogic/LayoutPersistence.swift
index f745acb42..b684b0be4 100644
--- a/supacode/Features/Terminal/BusinessLogic/LayoutPersistence.swift
+++ b/supacode/Features/Terminal/BusinessLogic/LayoutPersistence.swift
@@ -23,6 +23,12 @@ enum LayoutPersistence {
let live = runtime.content(for: contentID)
if let live {
overlaid.panes[paneIndex].tabs[tabIndex].content = live.snapshot()
+ // Reported titles never enter the layout reducer, so while the content
+ // is live the saved record pulls its latest one here.
+ overlaid.panes[paneIndex].tabs[tabIndex].title = TabTitle.stored(
+ for: overlaid.panes[paneIndex].tabs[tabIndex],
+ chrome: live.chrome
+ )
}
guard case .terminal(let state) = overlaid.panes[paneIndex].tabs[tabIndex].content.state
else { continue }
diff --git a/supacode/Features/Terminal/BusinessLogic/LayoutSurfaceConduit.swift b/supacode/Features/Terminal/BusinessLogic/LayoutSurfaceConduit.swift
index 7dcf3bf4c..9ad44b744 100644
--- a/supacode/Features/Terminal/BusinessLogic/LayoutSurfaceConduit.swift
+++ b/supacode/Features/Terminal/BusinessLogic/LayoutSurfaceConduit.swift
@@ -41,7 +41,7 @@ struct LayoutSurfaceConduit {
let host = host
view.bridge.onTitleChange = { [weak view] title in
guard let view, isLive(view) else { return }
- host.sendLayoutAction(.runtime(.titleChanged(id: contentID, title: title)))
+ host.updateReportedTitle(for: contentID, title: title)
}
// Layout topology belongs to the app's own chords, menus, and palette;
// a Ghostty keybind for it is consumed and ignored so the terminal can
diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift
index 574d2d136..8c5afae78 100644
--- a/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift
+++ b/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift
@@ -27,6 +27,10 @@ final class WorktreeContentHost {
/// Routes a topology mutation into the worktree's `LayoutFeature`.
@ObservationIgnored var sendLayoutAction: (LayoutFeature.Action) -> Void = { _ in }
@ObservationIgnored var onNotificationReceived: ((UUID, String, String, Bool) -> Void)?
+ /// A content reported a new title. Nothing in TCA moved (the title lives on
+ /// the content's chrome), so this exists only to re-arm the persistence
+ /// debounce, which pulls the title back at snapshot time.
+ @ObservationIgnored var onReportedTitleChanged: (() -> Void)?
@ObservationIgnored var onNotificationIndicatorChanged: (() -> Void)?
@ObservationIgnored var onFocusChanged: ((UUID) -> Void)?
@ObservationIgnored var onFocusedSurfaceColorChanged: (() -> Void)?
@@ -541,7 +545,28 @@ final class WorktreeContentHost {
/// The tab's content-owned strip chrome, nil for non-terminal contents.
private func terminalChrome(for tabID: TabID) -> TerminalTabChrome? {
guard let contentID = tab(withID: tabID)?.content.id else { return nil }
- return runtime.content(for: contentID)?.chrome as? TerminalTabChrome
+ return terminalChrome(for: contentID)
+ }
+
+ private func terminalChrome(for contentID: ContentID) -> TerminalTabChrome? {
+ runtime.content(for: contentID)?.chrome as? TerminalTabChrome
+ }
+
+ /// A live or dormant terminal reported a title. Agent TUIs rewrite it several
+ /// times a second, so it lands on the content's observable chrome (only that
+ /// one tab label re-renders) and never as a layout action. The lock is
+ /// resolved at display and snapshot time, so a script tab's title survives its
+ /// shell's reports.
+ func updateReportedTitle(for contentID: ContentID, title: String) {
+ // Shells clear the title mid-command and reset it at the next prompt; ignore
+ // the empty report so the tab label holds its last real title instead of
+ // flashing to the layout's creation-time name.
+ let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, let chrome = terminalChrome(for: contentID),
+ chrome.reportedTitle != trimmed
+ else { return }
+ chrome.reportedTitle = trimmed
+ onReportedTitleChanged?()
}
func emitTaskStatusIfChanged() {
@@ -693,7 +718,7 @@ final class WorktreeContentHost {
if tabID(containing: surfaceID) != nil,
let title = liveSurface(surfaceID)?.bridge.state.title, !title.isEmpty
{
- sendLayoutAction(.runtime(.titleChanged(id: ContentID(rawValue: surfaceID), title: title)))
+ updateReportedTitle(for: ContentID(rawValue: surfaceID), title: title)
}
emitFocusChangedIfNeeded(surfaceID)
}
@@ -1186,7 +1211,7 @@ final class WorktreeContentHost {
private func updateDormantTabTitle(surfaceID: UUID, title: String) {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, isDormantSurface(surfaceID) else { return }
- sendLayoutAction(.runtime(.titleChanged(id: ContentID(rawValue: surfaceID), title: trimmed)))
+ updateReportedTitle(for: ContentID(rawValue: surfaceID), title: trimmed)
}
/// Full teardown on prune or quit: watchers stop, bookkeeping clears.
diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
index 3692b46d8..987e191f8 100644
--- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
+++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
@@ -760,6 +760,12 @@ final class WorktreeTerminalManager {
self?.emitNotificationIndicatorCountIfNeeded()
self?.emitProjection(for: worktree.id)
}
+ // Only the debounce: the title itself is read back off the chrome when the
+ // snapshot is built, so a title storm costs one coalesced write, not one
+ // store send per report.
+ host.onReportedTitleChanged = { [weak self] in
+ self?.markLayoutDirty(worktreeID: worktree.id)
+ }
host.onFocusChanged = { [weak self] surfaceID in
self?.emit(.focusChanged(worktreeID: worktree.id, surfaceID: surfaceID))
self?.refreshFocusedSurfaceBackground()
@@ -858,6 +864,17 @@ final class WorktreeTerminalManager {
)
}
+ /// Hands the content's last reported title back to the layout. The title is
+ /// carried by the content's chrome alone, so every teardown that keeps the tab
+ /// (a reattach rebuild, quit-time termination) must commit it first or the tab
+ /// falls back to its creation-time name.
+ private func commitReportedTitle(of contentID: ContentID, worktreeID: Worktree.ID) {
+ guard let title = ContentRuntime.liveValue.content(for: contentID)?.chrome?.reportedTitle,
+ !title.isEmpty
+ else { return }
+ sendLayout(worktreeID, .runtime(.titleCommitted(id: contentID, title: title)))
+ }
+
/// An unexpected zmx exit: probe the session, then spare, kill, or reattach.
func handleUnexpectedZmxClose(_ view: GhosttySurfaceView, worktreeID: Worktree.ID) {
let surfaceID = view.id
@@ -883,6 +900,7 @@ final class WorktreeTerminalManager {
}
if session.clients == 0 {
// Reattachable: rebuild the same content at its persisted geometry.
+ self.commitReportedTitle(of: ContentID(rawValue: surfaceID), worktreeID: worktreeID)
ContentRuntime.liveValue.remove(ContentID(rawValue: surfaceID), tombstone: false)
self.sendLayout(worktreeID, .wakeTab(id: tabID))
return
@@ -1737,18 +1755,30 @@ final class WorktreeTerminalManager {
/// so "Quit and Terminate" must explicitly sweep orphan sessions or they
/// would survive forever.
func terminateAllSessions(killBudget: Duration = WorktreeTerminalManager.quitKillBudget) async {
- let trackedSurfaceIDs = hosts.values.flatMap(\.allSurfaceIDs)
- let trackedSessionIDs = Set(trackedSurfaceIDs.map(ZmxSessionID.make(surfaceID:)))
+ // Captured before `tearDown`, which clears the hosts' surface tracking.
+ let trackedByWorktree = hosts.flatMap { worktreeID, host in
+ host.allSurfaceIDs.map { (worktreeID: worktreeID, surfaceID: $0) }
+ }
+ let trackedSessionIDs = Set(trackedByWorktree.map { ZmxSessionID.make(surfaceID: $0.surfaceID) })
// "Quit and Terminate" promises nothing keeps running, so the host-side
// sessions of remote worktrees are swept too (best-effort over SSH).
let trackedRemoteSessions = Self.remoteSessions(in: Array(hosts.values))
+ // Commit reported titles before teardown: the content is still live, so
+ // this reaches the layout before the snapshot save empties the runtime. A
+ // commit after `tearDown` would re-run layout lifecycle reconciliation,
+ // which sees the removed renderers as dormant and restarts the very session
+ // watchers this teardown just stopped (the process survives a Terminate All).
+ for entry in trackedByWorktree {
+ commitReportedTitle(of: ContentID(rawValue: entry.surfaceID), worktreeID: entry.worktreeID)
+ }
for host in hosts.values {
host.tearDown()
}
- for surfaceID in trackedSurfaceIDs {
- guard let content = ContentRuntime.liveValue.content(for: ContentID(rawValue: surfaceID)) else { continue }
+ for entry in trackedByWorktree {
+ let contentID = ContentID(rawValue: entry.surfaceID)
+ guard let content = ContentRuntime.liveValue.content(for: contentID) else { continue }
content.hibernate()
- ContentRuntime.liveValue.remove(content.id, tombstone: false)
+ ContentRuntime.liveValue.remove(contentID, tombstone: false)
}
emitHasAnyTerminalSurfaceIfNeeded()
// This instance's tracked local sessions are killed. A remote surface's
diff --git a/supacode/Features/Terminal/Content/TabChrome.swift b/supacode/Features/Terminal/Content/TabChrome.swift
index 648240089..45e6d11c7 100644
--- a/supacode/Features/Terminal/Content/TabChrome.swift
+++ b/supacode/Features/Terminal/Content/TabChrome.swift
@@ -16,6 +16,35 @@ protocol TabChrome: AnyObject {
/// Whether the terminal refuses input (a completed blocking script's parked
/// shell). The tab's own `isLocked` drives the visible lock marker.
var isReadOnly: Bool { get }
+ /// The title the content last reported, nil until it reports one (and nil for
+ /// content kinds that never report one). Agent TUIs rewrite it several times a
+ /// second, so it lives here rather than as a layout reducer action, and only
+ /// one tab's label re-renders on a report.
+ var reportedTitle: String? { get }
+}
+
+/// Resolves what a tab shows, and what the layout should persist for it, from
+/// the layout's own title and the content's live reported title.
+@MainActor
+enum TabTitle {
+ /// The title the record persists: the content's live report when it has one,
+ /// else the layout's own. A locked tab owns its title (a shell report never
+ /// reaches it), and the user override is excluded (it persists in its field).
+ static func stored(for tab: TabItem, chrome: (any TabChrome)?) -> String {
+ guard !tab.isLocked, let reported = chrome?.reportedTitle, !reported.isEmpty else {
+ return tab.title
+ }
+ return reported
+ }
+
+ /// What the tab displays: a user override wins over the reported title.
+ static func resolved(for tab: TabItem, chrome: (any TabChrome)?) -> String {
+ tab.customTitle ?? stored(for: tab, chrome: chrome)
+ }
+
+ static func resolved(for tab: TabItem, runtime: ContentRuntime) -> String {
+ resolved(for: tab, chrome: runtime.content(for: tab.content.id)?.chrome)
+ }
}
/// Terminal chrome, written by the content host and the agent-presence
@@ -27,6 +56,7 @@ final class TerminalTabChrome: TabChrome {
var isWorking = false
var progress: TerminalTabProgressDisplay?
var isReadOnly = false
+ var reportedTitle: String?
var accessory: AnyView? {
guard !agents.isEmpty else { return nil }
diff --git a/supacode/Features/Terminal/Models/SplitTree.swift b/supacode/Features/Terminal/Models/SplitTree.swift
index 35c4008d8..9d0d8d5b6 100644
--- a/supacode/Features/Terminal/Models/SplitTree.swift
+++ b/supacode/Features/Terminal/Models/SplitTree.swift
@@ -359,47 +359,6 @@ nonisolated struct SplitTree: Equatable {
return best?.leaf
}
- var structuralIdentity: StructuralIdentity {
- StructuralIdentity(self)
- }
-
- struct StructuralIdentity: Hashable {
- private let root: Node?
- private let zoomed: Node?
-
- init(_ tree: SplitTree) {
- self.root = tree.root
- self.zoomed = tree.zoomed
- }
-
- static func == (lhs: Self, rhs: Self) -> Bool {
- areNodesStructurallyEqual(lhs.root, rhs.root)
- && areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed)
- }
-
- func hash(into hasher: inout Hasher) {
- hasher.combine(0)
- if let root {
- root.hashStructure(into: &hasher)
- }
- hasher.combine(1)
- if let zoomed {
- zoomed.hashStructure(into: &hasher)
- }
- }
-
- private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool {
- switch (lhs, rhs) {
- case (nil, nil):
- return true
- case (let node1?, let node2?):
- return node1.isStructurallyEqual(to: node2)
- default:
- return false
- }
- }
- }
-
private init(root: Node?, zoomed: Node?) {
self.root = root
self.zoomed = zoomed
@@ -743,52 +702,6 @@ nonisolated extension SplitTree.Node {
return slots
}
}
-
- var structuralIdentity: StructuralIdentity {
- StructuralIdentity(self)
- }
-
- struct StructuralIdentity: Hashable {
- private let node: SplitTree.Node
-
- init(_ node: SplitTree.Node) {
- self.node = node
- }
-
- static func == (lhs: Self, rhs: Self) -> Bool {
- lhs.node.isStructurallyEqual(to: rhs.node)
- }
-
- func hash(into hasher: inout Hasher) {
- node.hashStructure(into: &hasher)
- }
- }
-
- fileprivate func isStructurallyEqual(to other: Node) -> Bool {
- switch (self, other) {
- case (.leaf(let view1), .leaf(let view2)):
- return view1 == view2
- case (.split(let split1), .split(let split2)):
- return split1.direction == split2.direction
- && split1.left.isStructurallyEqual(to: split2.left)
- && split1.right.isStructurallyEqual(to: split2.right)
- default:
- return false
- }
- }
-
- fileprivate func hashStructure(into hasher: inout Hasher) {
- switch self {
- case .leaf(let view):
- hasher.combine(0)
- hasher.combine(view)
- case .split(let split):
- hasher.combine(1)
- hasher.combine(split.direction)
- split.left.hashStructure(into: &hasher)
- split.right.hashStructure(into: &hasher)
- }
- }
}
nonisolated extension SplitTree.Spatial {
diff --git a/supacode/Features/Terminal/Reducer/LayoutFeature.swift b/supacode/Features/Terminal/Reducer/LayoutFeature.swift
index 1b35cf6f2..ccef4dc9f 100644
--- a/supacode/Features/Terminal/Reducer/LayoutFeature.swift
+++ b/supacode/Features/Terminal/Reducer/LayoutFeature.swift
@@ -130,10 +130,16 @@ struct LayoutFeature {
@Presents var alert: AlertState?
}
- /// Events pushed by the content-runtime plumbing.
+ /// Events pushed by the content-runtime plumbing. Individual title reports are
+ /// NOT here: they arrive at keystroke frequency, so they land on the content's
+ /// own `TabChrome` and reach persistence through the snapshot pull. Only the
+ /// once-per-content commit below crosses into the layout.
nonisolated enum RuntimeEvent: Equatable, Sendable {
case killConfirmed(id: ContentID)
- case titleChanged(id: ContentID, title: String)
+ /// The content's last reported title, handed back before the content leaves
+ /// the runtime; the chrome that carried it dies with it, and the layout's
+ /// own title is what the tab falls back to afterwards.
+ case titleCommitted(id: ContentID, title: String)
}
/// What `focusPane` aims at. One payload instead of two `focusPane`
@@ -220,18 +226,19 @@ struct LayoutFeature {
private static let logger = SupaLogger("LayoutFeature")
- // Ratio drags and title reports arrive at frame rate and cannot alter
- // structure; exempt them from the per-action layout walk.
+ // Ratio drags arrive at frame rate and the inline rename begin/end toggles are
+ // transient; neither alters structure, so exempt them from the per-action walk.
private static func isExemptFromConsistencyCheck(_ action: Action) -> Bool {
switch action {
- case .resizePane, .runtime(.titleChanged), .beginTabRename, .endTabRename:
+ case .resizePane, .beginTabRename, .endTabRename:
return true
case .newTab, .splitPane, .closeTab, .closePane, .selectTab, .renameTab, .focusPane,
.moveTab, .moveTabToSplit, .moveTabToSpanningSplit, .enterWindowMode, .exitWindowMode,
.equalizePanes, .toggleZoom, .hibernateTab, .wakeTab, .runtime(.killConfirmed),
- .contentRequestedClose, .contentRequestedNewTab, .contentRequestedSplit,
- .contentRequestedFocus, .contentRequestedFocusSplit, .contentRequestedToggleZoom,
- .contentRequestedResize, .contentRequestedGotoTab, .contentRequestedMoveTab, .alert:
+ .runtime(.titleCommitted), .contentRequestedClose, .contentRequestedNewTab,
+ .contentRequestedSplit, .contentRequestedFocus, .contentRequestedFocusSplit,
+ .contentRequestedToggleZoom, .contentRequestedResize, .contentRequestedGotoTab,
+ .contentRequestedMoveTab, .alert:
return false
}
}
@@ -566,19 +573,21 @@ extension LayoutFeature {
guard var pane = state.layout.pane(containingTab: tabID), let index = pane.tabs.index(id: tabID) else {
return .none
}
- let reaping = reap(pane.tabs[index].content.id, worktree: state.id)
+ let contentID = pane.tabs[index].content.id
releaseTabBookkeeping(&state, tabID: tabID)
pane.tabs.remove(at: index)
- guard !pane.tabs.isEmpty else {
+ if pane.tabs.isEmpty {
collapse(&state, paneID: pane.id)
- return reaping
- }
- if pane.selectedTabID == tabID {
- // Selection retargets to the previous tab, else the first.
- pane.selectedTabID = index > 0 ? pane.tabs[index - 1].id : pane.tabs.first?.id
+ } else {
+ if pane.selectedTabID == tabID {
+ // Selection retargets to the previous tab, else the first.
+ pane.selectedTabID = index > 0 ? pane.tabs[index - 1].id : pane.tabs.first?.id
+ }
+ state.layout.panes[id: pane.id] = pane
}
- state.layout.panes[id: pane.id] = pane
- return reaping
+ // Reap after the tree has collapsed so the collapse is the turn's state
+ // mutation and the surface teardown runs off it, not before it.
+ return reap(contentID, worktree: state.id)
}
private func reduceMoveTab(
@@ -909,13 +918,14 @@ extension LayoutFeature {
private func reduceClosePane(_ state: inout State, paneID: PaneID) -> Effect {
guard let pane = state.layout.panes[id: paneID] else { return .none }
- // Merged: one hung kill must not queue the siblings behind it.
- let reaping = Effect.merge(pane.tabs.map { reap($0.content.id, worktree: state.id) })
for tab in pane.tabs {
releaseTabBookkeeping(&state, tabID: tab.id)
}
collapse(&state, paneID: paneID)
- return reaping
+ // Reap after the tree has collapsed so the collapse is the turn's state
+ // mutation and the surface teardown runs off it, not before it. Merged: one
+ // hung kill must not queue the siblings behind it.
+ return .merge(pane.tabs.map { reap($0.content.id, worktree: state.id) })
}
private func reduceResizePane(_ state: inout State, node: SplitTree.Node, ratio: Double) -> Effect {
@@ -1035,13 +1045,11 @@ extension LayoutFeature {
switch event {
case .killConfirmed(let contentID):
contentRuntime.confirmKill(contentID)
- case .titleChanged(let contentID, let title):
+ case .titleCommitted(let contentID, let title):
guard let located = state.layout.tab(containingContent: contentID) else { break }
- // A script tab owns its title; shell reports must not overwrite it.
- guard !located.tab.isLocked else { break }
- // TUIs rewrite their title constantly; skip no-op writes so an unchanged
- // title does not re-render the tab strip on every report.
- guard located.tab.title != title else { break }
+ // A script tab owns its title; shell reports must not overwrite it. Skip a
+ // no-op write so an identical commit does not re-render the tab strip.
+ guard !located.tab.isLocked, located.tab.title != title else { break }
var pane = located.pane
pane.tabs[id: located.tab.id]?.title = title
state.layout.panes[id: pane.id] = pane
diff --git a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift
index ac8978aaf..75617805d 100644
--- a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift
+++ b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift
@@ -22,11 +22,21 @@ struct TerminalsFeature {
/// Grace window a tab must stay hidden before it hibernates.
static let hibernationGraceWindow: Duration = .seconds(5 * 60)
+ /// How many most-recently-selected worktrees keep their visible tabs live no
+ /// matter how long they stay deselected, so flipping back among that set never
+ /// pays a rewake.
+ static let liveWorktreeLimit = 3
+
/// Per-tab cancellation key for the hibernation grace timer.
nonisolated enum HibernationTimerID: Hashable, Sendable {
case tab(TabID)
}
+ /// Cancellation key for the process-wide memory-pressure subscription.
+ nonisolated enum CancelID: Hashable, Sendable {
+ case memoryPressure
+ }
+
@ObservableState
struct State: Equatable {
/// Per-worktree pane and tab topology, hydrated from `layouts.json` v2.
@@ -37,6 +47,10 @@ struct TerminalsFeature {
/// The selected worktree; only its panes' selected tabs are visible, so
/// everything else is a hibernation candidate.
var selectedWorktreeID: Worktree.ID?
+ /// Most-recently-selected worktrees, newest first, capped at
+ /// `liveWorktreeLimit`. Their visible panes' selected tabs never arm a grace
+ /// timer, so flipping back among them is instant.
+ var recentWorktreeIDs: [Worktree.ID] = []
/// Tabs with an armed hibernation grace timer.
var hibernationArmedTabs: Set = []
/// Hidden-but-ineligible tabs already logged, so a permanently ineligible
@@ -49,6 +63,8 @@ struct TerminalsFeature {
enum Action {
case layouts(IdentifiedActionOf)
+ /// Subscribes the memory-pressure source the hibernation policy reacts to.
+ case task
/// The migrated layouts file finished loading. Consistent records become
/// `LayoutFeature` states; inconsistent ones fall back to a fresh layout
/// on first use.
@@ -66,15 +82,18 @@ struct TerminalsFeature {
case hibernationPolicyChanged
/// A tab's grace timer fired; re-verify and hibernate or re-arm.
case hibernationGraceElapsed(worktreeID: Worktree.ID, tabID: TabID)
+ /// The system reported memory pressure: drop the recency budget to the
+ /// selection and hibernate the hidden tabs now, skipping the grace window.
+ case memoryPressureWarning
}
private static let logger = SupaLogger("TerminalsFeature")
- // Ratio drags and title reports arrive at high frequency and cannot flip
- // tab visibility; skip the layout-wide re-diff for them.
+ // Ratio drags, the inline rename begin/end toggles, and a teardown title
+ // commit never flip tab visibility, so skip the layout-wide re-diff for them.
private static func canAffectVisibility(_ action: LayoutFeature.Action) -> Bool {
switch action {
- case .resizePane, .runtime(.titleChanged), .beginTabRename, .endTabRename:
+ case .resizePane, .beginTabRename, .endTabRename, .runtime(.titleCommitted):
return false
case .newTab, .splitPane, .closeTab, .closePane, .selectTab, .renameTab, .focusPane,
.moveTab, .moveTabToSplit, .moveTabToSpanningSplit, .enterWindowMode, .exitWindowMode,
@@ -88,6 +107,7 @@ struct TerminalsFeature {
@Dependency(ContentRuntime.self) private var contentRuntime
@Dependency(LayoutChangeObserver.self) private var layoutChangeObserver
+ @Dependency(MemoryPressureClient.self) private var memoryPressure
@Dependency(\.continuousClock) private var clock
var body: some Reducer {
@@ -106,6 +126,14 @@ struct TerminalsFeature {
case .layouts:
return reconcileHibernation(&state)
+ case .task:
+ return .run { [memoryPressure] send in
+ for await _ in memoryPressure.warnings() {
+ await send(.memoryPressureWarning)
+ }
+ }
+ .cancellable(id: CancelID.memoryPressure, cancelInFlight: true)
+
case .attachLayout(let worktreeID, let titlePrefix):
if state.layouts[id: worktreeID] == nil {
state.layouts.append(LayoutFeature.State(id: worktreeID, layout: PaneLayout()))
@@ -117,10 +145,12 @@ struct TerminalsFeature {
// Bookkeeping is NOT pre-cleared: the reconcile below must still see
// the armed entries to emit their timer cancellations.
state.layouts.remove(id: worktreeID)
+ state.recentWorktreeIDs.removeAll { $0 == worktreeID }
return reconcileHibernation(&state)
case .selectedWorktreeChanged(let worktreeID):
state.selectedWorktreeID = worktreeID
+ Self.recordSelection(worktreeID, in: &state.recentWorktreeIDs)
return reconcileHibernation(&state)
case .hibernationPolicyChanged:
@@ -129,6 +159,9 @@ struct TerminalsFeature {
case .hibernationGraceElapsed(let worktreeID, let tabID):
return reduceHibernationGraceElapsed(&state, worktreeID: worktreeID, tabID: tabID)
+ case .memoryPressureWarning:
+ return reduceMemoryPressureWarning(&state)
+
case .layoutsHydrated(let file):
state.layoutsAreReadOnly = file.schemaVersion > LayoutsFile.currentSchemaVersion
// The runtime keys globally by content id and hibernation by tab id, so
@@ -188,12 +221,40 @@ extension TerminalsFeature {
return layout.id == selectedWorktreeID && visiblePanes.contains(pane.id)
}
+ /// Moves a selection to the front of the recency list, capped at
+ /// `liveWorktreeLimit`. Deselecting keeps the list, so the worktree just left
+ /// stays the most recent.
+ private static func recordSelection(_ worktreeID: Worktree.ID?, in recents: inout [Worktree.ID]) {
+ guard let worktreeID else { return }
+ recents.removeAll { $0 == worktreeID }
+ recents.insert(worktreeID, at: 0)
+ if recents.count > liveWorktreeLimit {
+ recents.removeLast(recents.count - liveWorktreeLimit)
+ }
+ }
+
+ /// Whether recency alone keeps this hidden tab live: it is the selected tab of
+ /// a visible pane (so stacked background tabs never qualify) and its worktree
+ /// is still inside the recency window.
+ private static func recencyRetains(
+ _ tab: TabItem,
+ pane: Pane,
+ in layout: LayoutFeature.State,
+ visiblePanes: Set,
+ recentWorktreeIDs: [Worktree.ID]
+ ) -> Bool {
+ guard pane.selectedTabID == tab.id, visiblePanes.contains(pane.id) else { return false }
+ return recentWorktreeIDs.contains(layout.id)
+ }
+
/// Diffs the hidden set against armed timers and wakes newly visible
/// hibernated tabs. Cheap enough to run after every layout action.
private func reconcileHibernation(_ state: inout State) -> Effect {
@Shared(.settingsFile) var settingsFile: SettingsFile
let enabled = settingsFile.global.terminalHibernationEnabled
- var hidden: Set = []
+ // Tabs that should hold an armed grace timer this pass; anything armed and
+ // absent here is cancelled below.
+ var keepArmed: Set = []
var allTabs: Set = []
var effects: [Effect] = []
for layout in state.layouts {
@@ -209,14 +270,24 @@ extension TerminalsFeature {
allTabs.insert(tab.id)
let isHidden = Self.isTabHidden(tab, pane: pane, paneShowsContent: showsContent)
if isHidden {
- hidden.insert(tab.id)
state.wakeRequestedTabs.remove(tab.id)
- guard enabled, !state.hibernationArmedTabs.contains(tab.id) else { continue }
- // Arm only live renderers; hibernated tabs have nothing to tear
- // down and re-arm on wake through this same funnel.
- guard contentRuntime.content(for: tab.content.id)?.renderer != nil else { continue }
- state.hibernationArmedTabs.insert(tab.id)
- effects.append(armGraceTimer(worktreeID: layout.id, tabID: tab.id))
+ // Recency keeps the top worktrees' visible tabs live, so a flip back
+ // among them never pays a rewake; they never arm.
+ if Self.recencyRetains(
+ tab, pane: pane, in: layout,
+ visiblePanes: visiblePanes, recentWorktreeIDs: state.recentWorktreeIDs
+ ) {
+ continue
+ }
+ // Only a hidden, enabled tab with a live renderer keeps a timer; a
+ // hibernated tab (renderer gone) has nothing left to tear down, so
+ // it falls out of `keepArmed` and any stale timer is cancelled.
+ guard enabled, contentRuntime.content(for: tab.content.id)?.renderer != nil else { continue }
+ keepArmed.insert(tab.id)
+ if !state.hibernationArmedTabs.contains(tab.id) {
+ state.hibernationArmedTabs.insert(tab.id)
+ effects.append(armGraceTimer(worktreeID: layout.id, tabID: tab.id))
+ }
} else if contentNeedsWake(tab) {
// The selection landed on a hibernated tab; wake it at its frozen
// geometry, once per visibility spell so a failed wake can't loop.
@@ -229,8 +300,9 @@ extension TerminalsFeature {
}
}
}
- // Cancel timers for tabs that became visible, vanished, or lost the flag.
- for armed in state.hibernationArmedTabs where !enabled || !hidden.contains(armed) {
+ // Cancel any armed tab no longer eligible: visible, vanished, recency-covered,
+ // renderer gone (hibernated), or the flag flipped off.
+ for armed in state.hibernationArmedTabs where !keepArmed.contains(armed) {
state.hibernationArmedTabs.remove(armed)
state.hibernationDeferralLogged.remove(armed)
effects.append(.cancel(id: HibernationTimerID.tab(armed)))
@@ -268,16 +340,28 @@ extension TerminalsFeature {
guard settingsFile.global.terminalHibernationEnabled else { return .none }
guard let layout = state.layouts[id: worktreeID],
let pane = layout.layout.pane(containingTab: tabID),
- let tab = pane.tabs[id: tabID],
+ let tab = pane.tabs[id: tabID]
+ else { return .none }
+ let visiblePanes = Set(layout.layout.tree.visibleLeaves())
+ guard
Self.isTabHidden(
tab,
pane: pane,
paneShowsContent: Self.paneShowsContent(
pane,
in: layout,
- visiblePanes: Set(layout.layout.tree.visibleLeaves()),
+ visiblePanes: visiblePanes,
selectedWorktreeID: state.selectedWorktreeID
)
+ ),
+ // Recency can cover a tab after its timer armed; the fire-time gate must
+ // agree with the arm-time one or a protected tab still hibernates.
+ !Self.recencyRetains(
+ tab,
+ pane: pane,
+ in: layout,
+ visiblePanes: visiblePanes,
+ recentWorktreeIDs: state.recentWorktreeIDs
)
else { return .none }
guard layout.alert == nil else {
@@ -285,7 +369,15 @@ extension TerminalsFeature {
state.hibernationArmedTabs.insert(tabID)
return armGraceTimer(worktreeID: worktreeID, tabID: tabID)
}
- guard contentRuntime.content(for: tab.content.id)?.isHibernatable == true else {
+ let content = contentRuntime.content(for: tab.content.id)
+ guard content?.isHibernatable == true else {
+ // Nothing left to hibernate (the renderer is already gone, e.g. a
+ // concurrent pressure sweep hibernated it after this timer fired): settle
+ // instead of re-arming a dead tab into a forever loop.
+ guard content?.renderer != nil else {
+ state.hibernationDeferralLogged.remove(tabID)
+ return .none
+ }
// Still hidden but momentarily ineligible; re-arm so a later
// eligibility flip still hibernates instead of wedging forever.
if state.hibernationDeferralLogged.insert(tabID).inserted {
@@ -297,4 +389,36 @@ extension TerminalsFeature {
state.hibernationDeferralLogged.remove(tabID)
return .send(.layouts(.element(id: worktreeID, action: .hibernateTab(id: tabID))))
}
+
+ /// Under pressure the recency budget is the first thing to go: keep only the
+ /// selection live and hibernate every hidden hibernatable tab now instead of
+ /// waiting out the grace window. Gated on the hibernation Beta flag.
+ private func reduceMemoryPressureWarning(_ state: inout State) -> Effect {
+ @Shared(.settingsFile) var settingsFile: SettingsFile
+ guard settingsFile.global.terminalHibernationEnabled else { return .none }
+ state.recentWorktreeIDs = state.selectedWorktreeID.map { [$0] } ?? []
+ var effects: [Effect] = []
+ for layout in state.layouts {
+ // A pending close confirmation keeps its worktree's tabs live; the grace
+ // path already exempts it, so the sweep must too.
+ guard layout.alert == nil else { continue }
+ let visiblePanes = Set(layout.layout.tree.visibleLeaves())
+ for pane in layout.layout.panes {
+ let showsContent = Self.paneShowsContent(
+ pane, in: layout, visiblePanes: visiblePanes, selectedWorktreeID: state.selectedWorktreeID)
+ for tab in pane.tabs where Self.isTabHidden(tab, pane: pane, paneShowsContent: showsContent) {
+ guard contentRuntime.content(for: tab.content.id)?.isHibernatable == true else { continue }
+ // Cancel the pending grace timer and hibernate through the fire-time
+ // action, which re-checks visibility just before teardown so a tab the
+ // user selects between this sweep and the hibernate is spared.
+ if state.hibernationArmedTabs.remove(tab.id) != nil {
+ effects.append(.cancel(id: HibernationTimerID.tab(tab.id)))
+ }
+ state.hibernationDeferralLogged.remove(tab.id)
+ effects.append(.send(.hibernationGraceElapsed(worktreeID: layout.id, tabID: tab.id)))
+ }
+ }
+ }
+ return effects.isEmpty ? .none : .merge(effects)
+ }
}
diff --git a/supacode/Features/Terminal/Views/LayoutContentView.swift b/supacode/Features/Terminal/Views/LayoutContentView.swift
index aa0185d05..fc40428f7 100644
--- a/supacode/Features/Terminal/Views/LayoutContentView.swift
+++ b/supacode/Features/Terminal/Views/LayoutContentView.swift
@@ -80,14 +80,14 @@ struct LayoutPaneTreeView: View {
var body: some View {
Group {
if let node = store.layout.tree.visibleNode {
- // Panes and the windowed set flow down by value from this `.id`
- // boundary, so a dismantling copy cannot retarget a content host to
- // post-swap state.
+ // Panes and the windowed set flow down by value, so a dismantling copy
+ // cannot retarget a content host to post-swap state. Identity lives on
+ // the leaves instead of the whole tree, so a split or close
+ // re-identifies only the branch it changed, never the survivor.
PaneNodeView(
node: node, panes: store.layout.panes, windowedPaneIDs: store.windowedPaneIDs,
store: store, renderContext: renderContext
)
- .id(store.layout.tree.structuralIdentity)
} else {
EmptyLayoutView()
}
@@ -199,8 +199,6 @@ private struct EmptyLayoutView: View {
/// One node of the pane tree: a split renders its children with a draggable
/// divider, a leaf renders its pane.
private struct PaneNodeView: View {
- private static let logger = SupaLogger("LayoutContentView")
-
let node: SplitTree.Node
let panes: IdentifiedArrayOf
/// Frozen alongside `panes`: a live read here would let a dismantling copy
@@ -212,28 +210,13 @@ private struct PaneNodeView: View {
var body: some View {
switch node {
case .leaf(let paneID):
- if let pane = panes[id: paneID] {
- if windowedPaneIDs.contains(paneID) {
- WindowedPanePlaceholderView(paneID: paneID, store: store, showWindow: renderContext.showWindowedPane)
- } else {
- PaneStripView(
- pane: pane, windowedPaneIDs: windowedPaneIDs, store: store,
- runtime: renderContext.runtime,
- unfocusedOverlay: renderContext.unfocusedOverlay,
- surfaceState: renderContext.surfaceState,
- isLifecycleBusy: renderContext.isLifecycleBusy,
- dragModel: renderContext.dragModel)
- }
- } else {
- // Tree and panes disagree; render an explicit fallback, never a hole.
- EmptyTerminalPaneView(
- message: "This pane is unavailable.",
- hint: Text("Reopen the worktree to rebuild its layout.")
- )
- .onAppear {
- Self.logger.error("Tree leaf \(paneID.rawValue) has no pane; layout state is inconsistent.")
- }
- }
+ // The pane's own id, not the tree's shape: a split or close elsewhere
+ // leaves this leaf's identity, and so its mounted surface, untouched.
+ PaneLeafView(
+ paneID: paneID, panes: panes, windowedPaneIDs: windowedPaneIDs,
+ store: store, renderContext: renderContext
+ )
+ .id(paneID)
case .split(let split):
PaneSplitView(
node: node, split: split, panes: panes, windowedPaneIDs: windowedPaneIDs,
@@ -242,6 +225,43 @@ private struct PaneNodeView: View {
}
}
+/// One leaf of the pane tree: the pane's strip, its windowed-pane placeholder,
+/// or an explicit fallback when the tree and panes disagree.
+private struct PaneLeafView: View {
+ private static let logger = SupaLogger("LayoutContentView")
+
+ let paneID: PaneID
+ let panes: IdentifiedArrayOf
+ let windowedPaneIDs: Set
+ let store: StoreOf
+ let renderContext: PaneRenderContext
+
+ var body: some View {
+ if let pane = panes[id: paneID] {
+ if windowedPaneIDs.contains(paneID) {
+ WindowedPanePlaceholderView(paneID: paneID, store: store, showWindow: renderContext.showWindowedPane)
+ } else {
+ PaneStripView(
+ pane: pane, windowedPaneIDs: windowedPaneIDs, store: store,
+ runtime: renderContext.runtime,
+ unfocusedOverlay: renderContext.unfocusedOverlay,
+ surfaceState: renderContext.surfaceState,
+ isLifecycleBusy: renderContext.isLifecycleBusy,
+ dragModel: renderContext.dragModel)
+ }
+ } else {
+ // Tree and panes disagree; render an explicit fallback, never a hole.
+ EmptyTerminalPaneView(
+ message: "This pane is unavailable.",
+ hint: Text("Reopen the worktree to rebuild its layout.")
+ )
+ .onAppear {
+ Self.logger.error("Tree leaf \(paneID.rawValue) has no pane; layout state is inconsistent.")
+ }
+ }
+ }
+}
+
/// A split of two child nodes; the divider drag resizes and a double-click
/// equalizes, both through the reducer.
private struct PaneSplitView: View {
@@ -801,7 +821,9 @@ private struct ContentHostView: NSViewRepresentable {
{
return
}
- hostedView = GhosttySurfaceScrollView(surfaceView: surface)
+ // Reuse the surface's own wrapper: a remount reparents it rather than
+ // rebuilding at zero size, so the IOSurface keeps its frames.
+ hostedView = surface.hostedView()
} else {
// Already showing the right renderer: nothing to do.
if container.subviews.first === renderer { return }
diff --git a/supacode/Features/Terminal/Views/PaneTabStripView.swift b/supacode/Features/Terminal/Views/PaneTabStripView.swift
index 8a6a6818e..579dbbddd 100644
--- a/supacode/Features/Terminal/Views/PaneTabStripView.swift
+++ b/supacode/Features/Terminal/Views/PaneTabStripView.swift
@@ -384,6 +384,7 @@ private struct PaneTabView: View {
HStack(spacing: TerminalTabBarMetrics.contentSpacing) {
PaneTabLabelView(
tab: tab,
+ title: TabTitle.resolved(for: tab, chrome: chrome),
isSelected: isSelected,
isDormant: isDormant,
accessory: chrome?.accessory,
@@ -546,7 +547,7 @@ private struct PaneTabView: View {
}
private var displayTitle: String {
- tab.customTitle ?? tab.title
+ TabTitle.resolved(for: tab, runtime: runtime)
}
private var contentOpacity: Double {
@@ -669,6 +670,7 @@ private struct PaneTabView: View {
/// title.
private struct PaneTabLabelView: View {
let tab: TabItem
+ let title: String
let isSelected: Bool
let isDormant: Bool
let accessory: AnyView?
@@ -703,7 +705,7 @@ private struct PaneTabLabelView: View {
.accessibilityHidden(true)
}
PaneTabTitleLabel(
- title: tab.customTitle ?? tab.title,
+ title: title,
isSelected: isSelected,
isShimmering: isShimmering
)
diff --git a/supacode/Features/Terminal/Views/PaneWindowManager.swift b/supacode/Features/Terminal/Views/PaneWindowManager.swift
index 2fb17f625..f601861cd 100644
--- a/supacode/Features/Terminal/Views/PaneWindowManager.swift
+++ b/supacode/Features/Terminal/Views/PaneWindowManager.swift
@@ -404,7 +404,7 @@ final class PaneWindowManager {
window.minSize = NSSize(width: 320, height: 240)
window.title =
terminalManager.layoutState(for: worktreeID)?.layout.panes[id: paneID]
- .flatMap(WindowedPaneRootView.title(for:)) ?? "Terminal"
+ .map { WindowedPaneRootView.title(for: $0, runtime: ContentRuntime.liveValue) } ?? "Terminal"
window.center()
cascadePoint = window.cascadeTopLeft(from: cascadePoint)
let headerModel = PaneWindowHeaderModel()
@@ -573,9 +573,9 @@ private struct WindowedPaneRootView: View {
)
}
.onAppear {
- updateWindowTitle(Self.title(for: pane))
+ updateWindowTitle(Self.title(for: pane, runtime: runtime))
}
- .onChange(of: Self.title(for: pane)) { _, title in
+ .onChange(of: Self.title(for: pane, runtime: runtime)) { _, title in
updateWindowTitle(title)
}
.focusedSceneAction(
@@ -687,9 +687,9 @@ private struct WindowedPaneRootView: View {
action(surface)
}
- static func title(for pane: Pane) -> String {
+ static func title(for pane: Pane, runtime: ContentRuntime) -> String {
guard let tab = pane.selectedTab else { return "Terminal" }
- return tab.customTitle ?? tab.title
+ return TabTitle.resolved(for: tab, runtime: runtime)
}
}
diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
index bae9a3101..082c40a3b 100644
--- a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
+++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
@@ -8,13 +8,17 @@ import UniformTypeIdentifiers
final class GhosttyRuntime {
private static let logger = SupaLogger("Ghostty")
- /// Live-pointer registries for C callbacks. A queued main-queue callback
- /// (e.g. a wakeup) can fire after its runtime deinit freed the app, so
- /// dereferencing the raw userdata/app pointer would be use-after-free;
- /// every resolution validates membership first. Registered in init,
- /// removed in deinit.
+ /// Live-pointer registries for C callbacks. A queued main-queue callback can
+ /// fire after the raw pointer it names was freed, so dereferencing it would be
+ /// use-after-free; the deferred handler validates membership first. App and
+ /// userdata are registered in init and removed in deinit; surfaces are
+ /// registered on `registerSurface` and removed on `unregisterSurface`, which
+ /// `closeSurface` calls synchronously before the deferred
+ /// `ghostty_surface_free`, so a background-thread action queued around the
+ /// close is dropped instead of hitting freed memory.
private static var liveUserdataBits: Set = []
private static var liveAppBits: Set = []
+ private static var liveSurfaceBits: Set = []
final class SurfaceReference {
let surface: ghostty_surface_t
@@ -183,6 +187,7 @@ final class GhosttyRuntime {
let ref = SurfaceReference(surface)
surfaceRefs.append(ref)
surfaceRefs = surfaceRefs.filter { $0.isValid }
+ Self.liveSurfaceBits.insert(UInt(bitPattern: surface))
if let lastColorScheme {
ghostty_surface_set_color_scheme(surface, lastColorScheme)
}
@@ -192,6 +197,7 @@ final class GhosttyRuntime {
func unregisterSurface(_ ref: SurfaceReference) {
ref.invalidate()
surfaceRefs = surfaceRefs.filter { $0.isValid }
+ Self.liveSurfaceBits.remove(UInt(bitPattern: ref.surface))
}
/// Reloads the full app config from disk and re-applies the current color scheme.
@@ -315,12 +321,25 @@ final class GhosttyRuntime {
guard let app else { return false }
let appBits = UInt(bitPattern: app)
if Thread.isMainThread {
+ // Synchronous: the caller is driving this surface right now (e.g. its
+ // creation, which emits the initial cell-size/title before the surface is
+ // registered), so it is live by construction.
return MainActor.assumeIsolated {
handleAction(appBits: appBits, target: target, action: action)
}
}
+ // A background surface thread can emit an action just before, or during, the
+ // surface's deferred free; capture its identity so the main-actor block can
+ // drop it if the surface was unregistered (closing/freed) in the meantime,
+ // rather than dereferencing a pointer that is about to be, or already is,
+ // freed.
+ let surfaceBits =
+ target.tag == GHOSTTY_TARGET_SURFACE
+ ? target.target.surface.map { UInt(bitPattern: $0) }
+ : nil
DispatchQueue.main.async {
MainActor.assumeIsolated {
+ if let surfaceBits, !liveSurfaceBits.contains(surfaceBits) { return }
_ = handleAction(appBits: appBits, target: target, action: action)
}
}
diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift
index add31fbad..d4a78546c 100644
--- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift
+++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift
@@ -276,7 +276,10 @@ final class GhosttySurfaceBridge {
return true
case GHOSTTY_ACTION_PWD:
- state.pwd = string(from: action.action.pwd.pwd)
+ // Shells re-emit OSC 7 per prompt; skip the no-op write and a11y posts.
+ let pwd = string(from: action.action.pwd.pwd)
+ guard pwd != state.pwd else { return true }
+ state.pwd = pwd
if let surfaceView {
NSAccessibility.post(element: surfaceView, notification: .valueChanged)
// VoiceOver does not reliably re-read the label on `.valueChanged` alone.
diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
index 1d2a586f0..c2ed76d96 100644
--- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
+++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
@@ -146,6 +146,20 @@ final class GhosttySurfaceView: NSView, Identifiable {
}
}
}
+ // Strong hold forms a surface<->wrapper cycle, so `closeSurface` must release
+ // it; `deinit` cannot free the surface until it has.
+ private var ownedScrollWrapper: GhosttySurfaceScrollView?
+
+ /// The view a content host mounts for this surface: its scroll wrapper, built
+ /// once and reused across remounts so a rebuild or worktree switch reparents
+ /// it and the live surface keeps its painted frames, instead of rebuilding at
+ /// zero size.
+ func hostedView() -> GhosttySurfaceScrollView {
+ if let ownedScrollWrapper { return ownedScrollWrapper }
+ let wrapper = GhosttySurfaceScrollView(surfaceView: self)
+ ownedScrollWrapper = wrapper
+ return wrapper
+ }
var onFocusChange: ((Bool) -> Void)?
/// Asks the owning state to re-derive activity because user input reached an
/// occluded surface, passing the window's fresh key/visibility readings so
@@ -292,6 +306,11 @@ final class GhosttySurfaceView: NSView, Identifiable {
MainActor.assumeIsolated {
SecureInput.shared.removeScoped(id)
}
+ // A live surface here means a teardown path bypassed `closeSurface`; the
+ // call below still frees it, off the turn.
+ if surface != nil {
+ assertionFailure("GhosttySurfaceView deallocated with a live surface; a teardown path bypassed closeSurface().")
+ }
closeSurface()
if let workingDirectoryCString {
free(workingDirectoryCString)
@@ -311,16 +330,36 @@ final class GhosttySurfaceView: NSView, Identifiable {
func closeSurface() {
clearNotificationObservers()
- if let surface {
- if let surfaceRef {
- runtime.unregisterSurface(surfaceRef)
- self.surfaceRef = nil
- }
+ // Break the surface<->wrapper cycle; the strong hold otherwise blocks deinit.
+ defer { ownedScrollWrapper = nil }
+ guard let surface else { return }
+ if let surfaceRef {
+ runtime.unregisterSurface(surfaceRef)
+ self.surfaceRef = nil
+ }
+ self.surface = nil
+ bridge.surface = nil
+ lastOcclusion = nil
+ lastSurfaceFocus = nil
+ // Hide before the free so the "[Process exited]" overlay can't paint while
+ // the layout collapses around the closing pane.
+ isHidden = true
+ // Free off the current turn on the main queue: `ghostty_surface_free` joins
+ // the surface's search, renderer, and IO threads and tears down the Metal
+ // renderer, which would otherwise block the reducer turn. The main queue,
+ // not a `Task`, runs the free outside the reducer's inherited task-local
+ // scope, where an isolated-deinit release it triggers can abort as an
+ // invalid free. Retain the runtime and bridge by hand across the free (a
+ // Sendable block can't capture them) so the Ghostty app stays alive and a
+ // synchronous callback during the free still resolves a live bridge; `self`
+ // is intentionally not captured, as the free never touches the surface's
+ // nsview.
+ let retainedRuntime = Unmanaged.passRetained(runtime)
+ let retainedBridge = Unmanaged.passRetained(bridge)
+ DispatchQueue.main.async {
ghostty_surface_free(surface)
- self.surface = nil
- bridge.surface = nil
- lastOcclusion = nil
- lastSurfaceFocus = nil
+ retainedBridge.release()
+ retainedRuntime.release()
}
}
@@ -543,7 +582,7 @@ final class GhosttySurfaceView: NSView, Identifiable {
guard surface != nil else { return }
guard self.focused != focused else { return }
self.focused = focused
- if focused {
+ if focused, bridge.state.bellCount != 0 {
bridge.state.bellCount = 0
}
setSurfaceFocus(focused)
@@ -691,7 +730,11 @@ final class GhosttySurfaceView: NSView, Identifiable {
interpretKeyEvents([event])
return
}
- bridge.state.bellCount = 0
+ // Guarded: an unconditional write invalidates every observer of the bridge
+ // state on every keystroke, key repeat included.
+ if bridge.state.bellCount != 0 {
+ bridge.state.bellCount = 0
+ }
let (translationEvent, translationMods) = translationState(event, surface: surface)
let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
keyTextAccumulator = []
diff --git a/supacode/Support/DebugCaseOutput.swift b/supacode/Support/DebugCaseOutput.swift
index d6e35ee0f..ffda5835c 100644
--- a/supacode/Support/DebugCaseOutput.swift
+++ b/supacode/Support/DebugCaseOutput.swift
@@ -11,15 +11,35 @@ extension Reducer where State: Equatable {
}
}
+#if DEBUG
+ private enum ActionLogging {
+ // Opt-in: the per-action state copy, reflective diff, and print are the
+ // single largest debug-build cost on the terminal hot path. Enable by
+ // setting SUPACODE_LOG_ACTIONS to a truthy value (1); a falsy or unset value
+ // leaves the wrapper a passthrough.
+ static let isEnabled: Bool = {
+ guard let value = ProcessInfo.processInfo.environment["SUPACODE_LOG_ACTIONS"] else {
+ return false
+ }
+ return !["", "0", "false", "no"].contains(value.lowercased())
+ }()
+ }
+#endif
+
struct LogActionsReducer: Reducer where Base.State: Equatable {
let base: Base
- private let logger = SupaLogger("TCA")
+ #if DEBUG
+ private let logger = SupaLogger("TCA")
+ #endif
func reduce(into state: inout Base.State, action: Base.Action) -> Effect {
- let actionLabel = debugCaseOutput(action)
- logger.debug("Action: \(actionLabel)")
#if DEBUG
+ guard ActionLogging.isEnabled else {
+ return base.reduce(into: &state, action: action)
+ }
+ let actionLabel = debugCaseOutput(action)
+ logger.debug("Action: \(actionLabel)")
let previousState = state
let effects = base.reduce(into: &state, action: action)
if previousState != state, let diff = CustomDump.diff(previousState, state) {
@@ -27,6 +47,7 @@ struct LogActionsReducer: Reducer where Base.State: Equatable {
}
return effects
#else
+ let actionLabel = debugCaseOutput(action)
SentrySDK.logger.info("Action: \(actionLabel)")
let breadcrumb = Breadcrumb(level: .debug, category: "action")
breadcrumb.message = actionLabel
diff --git a/supacodeTests/GhosttySurfaceViewTests.swift b/supacodeTests/GhosttySurfaceViewTests.swift
index 7fbcf8e3c..e7a81dba8 100644
--- a/supacodeTests/GhosttySurfaceViewTests.swift
+++ b/supacodeTests/GhosttySurfaceViewTests.swift
@@ -513,6 +513,7 @@ struct GhosttySurfaceViewTests {
initialGeometry: .fallback,
context: GHOSTTY_SURFACE_CONTEXT_TAB
)
+ defer { surfaceView.closeSurface() }
let wrapper = GhosttySurfaceScrollView(surfaceView: surfaceView)
#expect(wrapper.safeAreaInsets.top == 0)
@@ -520,4 +521,70 @@ struct GhosttySurfaceViewTests {
#expect(wrapper.safeAreaInsets.bottom == 0)
#expect(wrapper.safeAreaInsets.right == 0)
}
+
+ // A remount must reparent the same wrapper, so the live IOSurface keeps its
+ // frames instead of the renderer rebuilding at zero size on every switch.
+ @Test func hostedViewReturnsTheSameWrapperAcrossRemounts() {
+ let surfaceView = GhosttySurfaceView(
+ id: UUID(),
+ runtime: GhosttyRuntime(),
+ workingDirectory: nil,
+ initialGeometry: .fallback,
+ context: GHOSTTY_SURFACE_CONTEXT_TAB
+ )
+ defer { surfaceView.closeSurface() }
+
+ let wrapper = surfaceView.hostedView()
+ #expect(surfaceView.hostedView() === wrapper)
+ // The reuse guard in the content host keys off `scrollWrapper`; it has to
+ // point at the very wrapper `hostedView()` vends.
+ #expect(surfaceView.scrollWrapper === wrapper)
+ }
+
+ // `closeSurface` must clear the cached wrapper so the surface<->wrapper cycle
+ // is broken: proven by `hostedView()` vending a fresh instance afterwards. If
+ // the release is ever dropped, the same wrapper comes back and both leak.
+ @Test func closeSurfaceClearsTheCachedWrapper() {
+ let surfaceView = GhosttySurfaceView(
+ id: UUID(),
+ runtime: GhosttyRuntime(),
+ workingDirectory: nil,
+ initialGeometry: .fallback,
+ context: GHOSTTY_SURFACE_CONTEXT_TAB
+ )
+ defer { surfaceView.closeSurface() }
+
+ let first = surfaceView.hostedView()
+ surfaceView.closeSurface()
+ let second = surfaceView.hostedView()
+
+ #expect(first !== second)
+ #expect(surfaceView.scrollWrapper === second)
+ }
+
+ // `closeSurface` detaches synchronously (surface cleared, view hidden so the
+ // "[Process exited]" overlay can't flash during a collapse) and defers the
+ // costly `ghostty_surface_free` off the turn, and a second close is a safe
+ // no-op. Only the hide needs a live renderer, absent when the display sleeps;
+ // the synchronous clear and the double-close no-op hold on both paths.
+ @Test func closeSurfaceDetachesSynchronouslyAndHidesTheView() {
+ let surfaceView = GhosttySurfaceView(
+ id: UUID(),
+ runtime: GhosttyRuntime(),
+ workingDirectory: nil,
+ initialGeometry: .fallback,
+ context: GHOSTTY_SURFACE_CONTEXT_TAB
+ )
+ let hadLiveRenderer = surfaceView.surface != nil
+
+ surfaceView.closeSurface()
+ #expect(surfaceView.surface == nil)
+ if hadLiveRenderer {
+ #expect(surfaceView.isHidden)
+ }
+
+ // Idempotent: the surface is already gone, so this must not double-free.
+ surfaceView.closeSurface()
+ #expect(surfaceView.surface == nil)
+ }
}
diff --git a/supacodeTests/LayoutFeatureTests.swift b/supacodeTests/LayoutFeatureTests.swift
index f0f5d47d1..4f6a9b3aa 100644
--- a/supacodeTests/LayoutFeatureTests.swift
+++ b/supacodeTests/LayoutFeatureTests.swift
@@ -1423,13 +1423,15 @@ struct LayoutFeatureTests {
#expect(harness.store.state.layout.isConsistent)
}
- @Test func titleChangedUpdatesTitleNotCustomTitle() async {
+ @Test func titleCommittedUpdatesTitleNotCustomTitle() async {
let harness = await makeHarness()
let paneID = harness.paneID
await harness.store.send(.renameTab(id: harness.tabID, title: "Custom")) {
$0.layout.panes[id: paneID]?.tabs[id: harness.tabID]?.customTitle = "Custom"
}
- await harness.store.send(.runtime(.titleChanged(id: harness.contentID, title: "zsh"))) {
+ // The commit is what survives the content leaving the runtime, so it lands
+ // on the layout's own title, never on the user's override.
+ await harness.store.send(.runtime(.titleCommitted(id: harness.contentID, title: "zsh"))) {
$0.layout.panes[id: paneID]?.tabs[id: harness.tabID]?.title = "zsh"
}
#expect(harness.store.state.layout.panes[id: paneID]?.tabs[id: harness.tabID]?.customTitle == "Custom")
@@ -1456,10 +1458,10 @@ struct LayoutFeatureTests {
#expect(killed.value.first?.worktree == WorktreeID("/tmp/layout-feature"))
}
- @Test func titleChangedWithTheSameTitleIsANoOp() async {
+ @Test func titleCommittedWithTheSameTitleIsANoOp() async {
let harness = await makeHarness()
- // The bootstrap tab is titled "One"; an identical report must not write.
- await harness.store.send(.runtime(.titleChanged(id: harness.contentID, title: "One")))
+ // The bootstrap tab is titled "One"; an identical commit must not write.
+ await harness.store.send(.runtime(.titleCommitted(id: harness.contentID, title: "One")))
#expect(harness.store.state.layout.isConsistent)
}
@@ -1838,7 +1840,7 @@ struct LayoutFeatureTests {
#expect(harness.store.state.layout.isConsistent)
}
- @Test func renameAndTitleReportsRespectTheTitleLock() async {
+ @Test func renameAndTitleCommitsRespectTheTitleLock() async {
let harness = await makeHarness()
let paneID = harness.paneID
// Lock the bootstrap tab's title, as a script tab would be.
@@ -1849,7 +1851,7 @@ struct LayoutFeatureTests {
locked.panes[id: paneID]?.tabs[id: harness.tabID]?.isLocked = true
let bundle = makeStore(layout: locked)
await bundle.store.send(.renameTab(id: harness.tabID, title: "Rejected"))
- await bundle.store.send(.runtime(.titleChanged(id: harness.contentID, title: "Shell Report")))
+ await bundle.store.send(.runtime(.titleCommitted(id: harness.contentID, title: "Shell Report")))
#expect(bundle.store.state.layout.panes[id: paneID]?.tabs[id: harness.tabID]?.customTitle == "Custom")
#expect(bundle.store.state.layout.panes[id: paneID]?.tabs[id: harness.tabID]?.title == "One")
}
diff --git a/supacodeTests/LayoutPersistenceTests.swift b/supacodeTests/LayoutPersistenceTests.swift
index 4ef985331..783782683 100644
--- a/supacodeTests/LayoutPersistenceTests.swift
+++ b/supacodeTests/LayoutPersistenceTests.swift
@@ -48,6 +48,38 @@ struct LayoutPersistenceTests {
)
}
+ @Test func overlaysTheChromesReportedTitle() {
+ let paneID = PaneID()
+ let tabID = TabID()
+ let contentID = ContentID()
+ let runtime = ContentRuntime()
+ let live = ChromeTabContent(id: contentID)
+ _ = runtime.provision(live, at: .fallback)
+ live.terminalChrome.reportedTitle = "claude"
+
+ // Reported titles never reach the reducer, so the snapshot pull is their
+ // only path to disk.
+ let record = LayoutPersistence.record(
+ for: layout(paneID: paneID, tabID: tabID, contentID: contentID),
+ runtime: runtime
+ )
+ #expect(record.layout.panes[id: paneID]?.tabs[id: tabID]?.title == "claude")
+ }
+
+ @Test func keepsTheStoredTitleForContentThatNeverReported() {
+ let paneID = PaneID()
+ let tabID = TabID()
+ let contentID = ContentID()
+ let runtime = ContentRuntime()
+ _ = runtime.provision(ChromeTabContent(id: contentID), at: .fallback)
+
+ let record = LayoutPersistence.record(
+ for: layout(paneID: paneID, tabID: tabID, contentID: contentID),
+ runtime: runtime
+ )
+ #expect(record.layout.panes[id: paneID]?.tabs[id: tabID]?.title == "One")
+ }
+
@Test func overlaysLiveSnapshotsOverStoredOnes() throws {
let paneID = PaneID()
let tabID = TabID()
diff --git a/supacodeTests/LogActionsReducerTests.swift b/supacodeTests/LogActionsReducerTests.swift
new file mode 100644
index 000000000..a2bf14f0a
--- /dev/null
+++ b/supacodeTests/LogActionsReducerTests.swift
@@ -0,0 +1,43 @@
+import ComposableArchitecture
+import Testing
+
+@testable import supacode
+
+/// A minimal hand-rolled reducer (no `@Reducer` macro) so the test exercises
+/// `logActions()` over a real base reduction with both a state write and an
+/// effect.
+private struct LogActionsCounter: Reducer {
+ struct State: Equatable {
+ var count = 0
+ var ranEffect = false
+ }
+ enum Action: Equatable {
+ case bump
+ case effectFired
+ }
+
+ func reduce(into state: inout State, action: Action) -> Effect {
+ switch action {
+ case .bump:
+ state.count += 1
+ return .run { await $0(.effectFired) }
+ case .effectFired:
+ state.ranEffect = true
+ return .none
+ }
+ }
+}
+
+/// `.logActions()` is a transparent passthrough when SUPACODE_LOG_ACTIONS is
+/// unset, which is the default in every build and in the test process. It must
+/// not change what the base reducer does to state or effects.
+@MainActor
+struct LogActionsReducerTests {
+ @Test func passthroughPreservesStateMutationAndEffects() async {
+ let store = TestStore(initialState: LogActionsCounter.State()) {
+ LogActionsCounter().logActions()
+ }
+ await store.send(.bump) { $0.count = 1 }
+ await store.receive(.effectFired) { $0.ranEffect = true }
+ }
+}
diff --git a/supacodeTests/TabTitleTests.swift b/supacodeTests/TabTitleTests.swift
new file mode 100644
index 000000000..a97092b23
--- /dev/null
+++ b/supacodeTests/TabTitleTests.swift
@@ -0,0 +1,67 @@
+import Testing
+
+@testable import supacode
+
+/// Locks the title contract now that reported titles live on the content's
+/// chrome instead of in `LayoutFeature`: nothing but the chrome carries a
+/// terminal's live title, and the layout's own title is the fallback.
+@MainActor
+struct TabTitleTests {
+ private func tab(
+ title: String = "Terminal 1",
+ customTitle: String? = nil,
+ isLocked: Bool = false,
+ contentID: ContentID = ContentID()
+ ) -> TabItem {
+ TabItem(
+ id: TabID(),
+ title: title,
+ customTitle: customTitle,
+ content: ContentSnapshot(
+ id: contentID,
+ state: .terminal(TerminalContentState(workingDirectory: nil))
+ ),
+ isLocked: isLocked
+ )
+ }
+
+ private func chrome(reporting title: String?) -> TerminalTabChrome {
+ let chrome = TerminalTabChrome()
+ chrome.reportedTitle = title
+ return chrome
+ }
+
+ @Test func theReportedTitleWinsOverTheLayoutsOwn() {
+ #expect(TabTitle.resolved(for: tab(), chrome: chrome(reporting: "zsh")) == "zsh")
+ #expect(TabTitle.stored(for: tab(), chrome: chrome(reporting: "zsh")) == "zsh")
+ }
+
+ @Test func anAbsentOrEmptyReportFallsBackToTheLayoutsTitle() {
+ #expect(TabTitle.resolved(for: tab(), chrome: chrome(reporting: nil)) == "Terminal 1")
+ #expect(TabTitle.resolved(for: tab(), chrome: chrome(reporting: "")) == "Terminal 1")
+ #expect(TabTitle.resolved(for: tab(), chrome: nil) == "Terminal 1")
+ }
+
+ @Test func aUserOverrideWinsOverTheReportButNeverPersists() {
+ let renamed = tab(customTitle: "Custom")
+ #expect(TabTitle.resolved(for: renamed, chrome: chrome(reporting: "zsh")) == "Custom")
+ // The override persists in its own field; folding it into the stored title
+ // would make clearing the rename restore the override text.
+ #expect(TabTitle.stored(for: renamed, chrome: chrome(reporting: "zsh")) == "zsh")
+ }
+
+ @Test func aLockedTabRefusesTheShellsReport() {
+ let script = tab(title: "Setup", isLocked: true)
+ #expect(TabTitle.resolved(for: script, chrome: chrome(reporting: "zsh")) == "Setup")
+ #expect(TabTitle.stored(for: script, chrome: chrome(reporting: "zsh")) == "Setup")
+ }
+
+ @Test func resolvingThroughTheRuntimeReadsTheRegisteredContentsChrome() {
+ let contentID = ContentID()
+ let runtime = ContentRuntime()
+ let content = ChromeTabContent(id: contentID)
+ _ = runtime.provision(content, at: .fallback)
+ content.terminalChrome.reportedTitle = "claude"
+ #expect(TabTitle.resolved(for: tab(contentID: contentID), runtime: runtime) == "claude")
+ }
+}
diff --git a/supacodeTests/TerminalsFeatureTests.swift b/supacodeTests/TerminalsFeatureTests.swift
index bd9ef0dc1..17e8dff2d 100644
--- a/supacodeTests/TerminalsFeatureTests.swift
+++ b/supacodeTests/TerminalsFeatureTests.swift
@@ -77,6 +77,8 @@ struct TerminalsFeatureTests {
let hiddenTab: TabID
let selectedContent: HibernatableContent
let hiddenContent: HibernatableContent
+ /// Drives injected memory-pressure warnings; `.task` must be sent to subscribe.
+ let pressure: AsyncStream.Continuation
}
/// One worktree, one pane, two tabs; both contents live in the runtime.
@@ -121,6 +123,7 @@ struct TerminalsFeatureTests {
focusedPaneID: paneID
)
let clock = TestClock()
+ let pressure = AsyncStream.makeStream()
let store = TestStore(
initialState: TerminalsFeature.State(layouts: [LayoutFeature.State(id: worktreeID, layout: layout)])
) {
@@ -129,6 +132,7 @@ struct TerminalsFeatureTests {
$0.continuousClock = clock
$0.contentRuntime = runtime
$0[ContentSessionKiller.self] = ContentSessionKiller(kill: { _, _ in })
+ $0[MemoryPressureClient.self] = MemoryPressureClient(warnings: { pressure.stream })
}
return HibernationHarness(
store: store,
@@ -139,7 +143,8 @@ struct TerminalsFeatureTests {
selectedTab: selectedTab,
hiddenTab: hiddenTab,
selectedContent: selectedContent,
- hiddenContent: hiddenContent
+ hiddenContent: hiddenContent,
+ pressure: pressure.continuation
)
}
@@ -149,6 +154,7 @@ struct TerminalsFeatureTests {
let harness = makeHibernationHarness()
await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
$0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
$0.hibernationArmedTabs = [harness.hiddenTab]
}
await harness.clock.advance(by: TerminalsFeature.hibernationGraceWindow)
@@ -168,6 +174,7 @@ struct TerminalsFeatureTests {
let harness = makeHibernationHarness()
await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
$0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
$0.hibernationArmedTabs = [harness.hiddenTab]
}
// Selecting the hidden tab makes it visible and hides the other one.
@@ -195,6 +202,7 @@ struct TerminalsFeatureTests {
let harness = makeHibernationHarness()
await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
$0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
$0.hibernationArmedTabs = [harness.hiddenTab]
}
$settingsFile.withLock { $0.global.terminalHibernationEnabled = false }
@@ -212,6 +220,7 @@ struct TerminalsFeatureTests {
harness.hiddenContent.claimsHibernation = false
await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
$0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
$0.hibernationArmedTabs = [harness.hiddenTab]
}
await harness.clock.advance(by: TerminalsFeature.hibernationGraceWindow)
@@ -239,6 +248,7 @@ struct TerminalsFeatureTests {
harness.selectedContent.hibernate()
await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
$0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
$0.hibernationArmedTabs = [harness.hiddenTab]
$0.wakeRequestedTabs = [harness.selectedTab]
}
@@ -270,6 +280,7 @@ struct TerminalsFeatureTests {
// selection.
await harness.store.send(.selectedWorktreeChanged(Worktree.ID("/tmp/other"))) {
$0.selectedWorktreeID = Worktree.ID("/tmp/other")
+ $0.recentWorktreeIDs = [Worktree.ID("/tmp/other")]
}
await harness.clock.advance(by: TerminalsFeature.hibernationGraceWindow)
await harness.store.receive(\.hibernationGraceElapsed) {
@@ -294,6 +305,7 @@ struct TerminalsFeatureTests {
}
await harness.store.send(.selectedWorktreeChanged(Worktree.ID("/tmp/other"))) {
$0.selectedWorktreeID = Worktree.ID("/tmp/other")
+ $0.recentWorktreeIDs = [Worktree.ID("/tmp/other")]
}
// Re-attaching withdraws the exemption: the selection is hidden again.
await harness.store.send(
@@ -397,6 +409,7 @@ struct TerminalsFeatureTests {
}
await store.send(.selectedWorktreeChanged(worktreeID)) {
$0.selectedWorktreeID = worktreeID
+ $0.recentWorktreeIDs = [worktreeID]
// Pane B sits behind the zoom, so its selection is hidden and arms.
$0.hibernationArmedTabs = [tabB]
}
@@ -413,6 +426,7 @@ struct TerminalsFeatureTests {
// Selecting another worktree hides both tabs; both arm.
await harness.store.send(.selectedWorktreeChanged(Worktree.ID("/tmp/other"))) {
$0.selectedWorktreeID = Worktree.ID("/tmp/other")
+ $0.recentWorktreeIDs = [Worktree.ID("/tmp/other")]
$0.hibernationArmedTabs = [harness.selectedTab, harness.hiddenTab]
}
await harness.store.send(.detachLayout(worktreeID: harness.worktreeID)) {
@@ -424,6 +438,256 @@ struct TerminalsFeatureTests {
await harness.store.finish()
}
+ @Test(.dependencies) func aRecentWorktreesSelectionStaysLiveWhileDeselected() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = true }
+ let harness = makeHibernationHarness()
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
+ $0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
+ $0.hibernationArmedTabs = [harness.hiddenTab]
+ }
+ // Deselecting keeps the worktree inside the recency window, so its visible
+ // selection is retained (never arms) even though it is now hidden; only the
+ // stacked tab stays armed.
+ await harness.store.send(.selectedWorktreeChanged(Worktree.ID("/tmp/other"))) {
+ $0.selectedWorktreeID = Worktree.ID("/tmp/other")
+ $0.recentWorktreeIDs = [Worktree.ID("/tmp/other"), harness.worktreeID]
+ }
+ // Advance well past the grace window: a retained selection never arms, so no
+ // amount of idle time hibernates it, while the stacked tab fires once.
+ await harness.clock.advance(by: TerminalsFeature.hibernationGraceWindow * 2)
+ await harness.store.receive(\.hibernationGraceElapsed) {
+ $0.hibernationArmedTabs = []
+ }
+ await harness.store.receive(\.layouts) {
+ $0.layouts[id: harness.worktreeID]?.renderEpoch = 1
+ }
+ // The stacked tab hibernated; the recency-retained selection did not.
+ #expect(harness.hiddenContent.renderer == nil)
+ #expect(harness.selectedContent.renderer != nil)
+ await harness.store.finish()
+ }
+
+ @Test(.dependencies) func aWorktreePushedOutOfTheRecencyWindowArmsItsSelection() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = true }
+ let harness = makeHibernationHarness()
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
+ $0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
+ $0.hibernationArmedTabs = [harness.hiddenTab]
+ }
+ // Visit enough other worktrees to push this one past `liveWorktreeLimit`.
+ let others = ["/tmp/o1", "/tmp/o2", "/tmp/o3"].map { Worktree.ID($0) }
+ await harness.store.send(.selectedWorktreeChanged(others[0])) {
+ $0.selectedWorktreeID = others[0]
+ $0.recentWorktreeIDs = [others[0], harness.worktreeID]
+ }
+ await harness.store.send(.selectedWorktreeChanged(others[1])) {
+ $0.selectedWorktreeID = others[1]
+ $0.recentWorktreeIDs = [others[1], others[0], harness.worktreeID]
+ }
+ await harness.store.send(.selectedWorktreeChanged(others[2])) {
+ $0.selectedWorktreeID = others[2]
+ // The worktree drops out of the window, so its selection loses recency
+ // cover and arms alongside the stacked tab.
+ $0.recentWorktreeIDs = [others[2], others[1], others[0]]
+ $0.hibernationArmedTabs = [harness.hiddenTab, harness.selectedTab]
+ }
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = false }
+ await harness.store.send(.hibernationPolicyChanged) {
+ $0.hibernationArmedTabs = []
+ }
+ await harness.store.finish()
+ }
+
+ @Test(.dependencies) func memoryPressureDropsRecencyAndHibernatesEveryHiddenTab() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = true }
+ let harness = makeHibernationHarness()
+ // The sweep fans out one hibernate per hidden tab; assert the outcome, not
+ // each cascading action.
+ harness.store.exhaustivity = .off
+ await harness.store.send(.task)
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID))
+ // Deselect but stay recent: without pressure the selection is retained live.
+ await harness.store.send(.selectedWorktreeChanged(Worktree.ID("/tmp/other")))
+ #expect(harness.selectedContent.renderer != nil)
+
+ harness.pressure.yield()
+ await harness.store.receive(\.memoryPressureWarning)
+ await harness.store.skipReceivedActions()
+
+ // Recency collapses to the current selection, and the deselected worktree's
+ // retained selection hibernates now instead of waiting out the grace window.
+ #expect(harness.store.state.recentWorktreeIDs == [Worktree.ID("/tmp/other")])
+ #expect(harness.selectedContent.renderer == nil)
+ #expect(harness.hiddenContent.renderer == nil)
+
+ harness.pressure.finish()
+ await harness.store.finish()
+ }
+
+ @Test(.dependencies) func memoryPressureSparesTheVisibleSelection() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = true }
+ let harness = makeHibernationHarness()
+ harness.store.exhaustivity = .off
+ await harness.store.send(.task)
+ // The worktree stays selected across the pressure event.
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID))
+
+ harness.pressure.yield()
+ await harness.store.receive(\.memoryPressureWarning)
+ await harness.store.skipReceivedActions()
+
+ // The on-screen selection survives; only the stacked tab hibernates.
+ #expect(harness.selectedContent.renderer != nil)
+ #expect(harness.hiddenContent.renderer == nil)
+
+ harness.pressure.finish()
+ await harness.store.finish()
+ }
+
+ @Test(.dependencies) func pressureSparesATabReselectedBeforeItsHibernateLands() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = true }
+ let harness = makeHibernationHarness()
+ harness.store.exhaustivity = .off
+ await harness.store.send(.task)
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID))
+ // Deselect so the selection tab is hidden and becomes a pressure target.
+ await harness.store.send(.selectedWorktreeChanged(Worktree.ID("/tmp/other")))
+
+ harness.pressure.yield()
+ await harness.store.receive(\.memoryPressureWarning)
+ // Before the queued hibernations land, the user flips back, making the
+ // selection visible again. Routing pressure through the fire-time action
+ // means its re-check spares the now-visible tab.
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID))
+ await harness.store.skipReceivedActions()
+
+ #expect(harness.selectedContent.renderer != nil)
+
+ // The reselect re-armed the stacked tab's grace timer; drain it so the
+ // store finishes with no in-flight effect.
+ await harness.clock.advance(by: TerminalsFeature.hibernationGraceWindow)
+ await harness.store.skipReceivedActions()
+ harness.pressure.finish()
+ await harness.store.finish()
+ }
+
+ @Test(.dependencies) func memoryPressureRespectsTheHibernationFlag() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = false }
+ let harness = makeHibernationHarness()
+ await harness.store.send(.task)
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
+ $0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
+ }
+ await harness.store.send(.selectedWorktreeChanged(Worktree.ID("/tmp/other"))) {
+ $0.selectedWorktreeID = Worktree.ID("/tmp/other")
+ $0.recentWorktreeIDs = [Worktree.ID("/tmp/other"), harness.worktreeID]
+ }
+
+ harness.pressure.yield()
+ // Hibernation disabled: the warning is a no-op. No surface is dropped and
+ // the recency budget is left intact.
+ await harness.store.receive(\.memoryPressureWarning)
+ #expect(harness.selectedContent.renderer != nil)
+ #expect(harness.hiddenContent.renderer != nil)
+ #expect(harness.store.state.recentWorktreeIDs == [Worktree.ID("/tmp/other"), harness.worktreeID])
+
+ harness.pressure.finish()
+ await harness.store.finish()
+ }
+
+ @Test(.dependencies) func theFireTimeGateSparesASelectionThatBecameRecent() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = true }
+ let harness = makeHibernationHarness()
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
+ $0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
+ $0.hibernationArmedTabs = [harness.hiddenTab]
+ }
+ // Deselect but stay recent: the selection is retained, never armed.
+ await harness.store.send(.selectedWorktreeChanged(Worktree.ID("/tmp/other"))) {
+ $0.selectedWorktreeID = Worktree.ID("/tmp/other")
+ $0.recentWorktreeIDs = [Worktree.ID("/tmp/other"), harness.worktreeID]
+ }
+ // A grace timer that fired for the now-retained selection (a race the
+ // arm-time cancel could miss) must not hibernate it: the fire-time gate wins.
+ await harness.store.send(
+ .hibernationGraceElapsed(worktreeID: harness.worktreeID, tabID: harness.selectedTab)
+ )
+ #expect(harness.selectedContent.renderer != nil)
+ // Drain the stacked tab's still-armed timer.
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = false }
+ await harness.store.send(.hibernationPolicyChanged) {
+ $0.hibernationArmedTabs = []
+ }
+ await harness.store.finish()
+ }
+
+ @Test(.dependencies) func graceElapsedSettlesInsteadOfReArmingAHibernatedTab() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = true }
+ let harness = makeHibernationHarness()
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
+ $0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
+ $0.hibernationArmedTabs = [harness.hiddenTab]
+ }
+ // The armed tab hibernates out of band (as a concurrent pressure sweep
+ // would), so when its grace timer fires there is nothing left to hibernate.
+ harness.hiddenContent.hibernate()
+ await harness.clock.advance(by: TerminalsFeature.hibernationGraceWindow)
+ await harness.store.receive(\.hibernationGraceElapsed) {
+ $0.hibernationArmedTabs = []
+ }
+ // Settled, not re-armed: a second window produces no further grace action.
+ await harness.clock.advance(by: TerminalsFeature.hibernationGraceWindow)
+ await harness.store.finish()
+ }
+
+ @Test(.dependencies) func reSelectingARecentWorktreeMovesItToFrontWithoutGrowing() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = false }
+ let harness = makeHibernationHarness()
+ let worktreeA = harness.worktreeID
+ let worktreeB = Worktree.ID("/tmp/b")
+ await harness.store.send(.selectedWorktreeChanged(worktreeA)) {
+ $0.selectedWorktreeID = worktreeA
+ $0.recentWorktreeIDs = [worktreeA]
+ }
+ await harness.store.send(.selectedWorktreeChanged(worktreeB)) {
+ $0.selectedWorktreeID = worktreeB
+ $0.recentWorktreeIDs = [worktreeB, worktreeA]
+ }
+ // Re-selecting the first worktree moves it to front without duplicating or growing the list.
+ await harness.store.send(.selectedWorktreeChanged(worktreeA)) {
+ $0.selectedWorktreeID = worktreeA
+ $0.recentWorktreeIDs = [worktreeA, worktreeB]
+ }
+ }
+
+ @Test(.dependencies) func detachLayoutRemovesTheWorktreeFromRecents() async {
+ @Shared(.settingsFile) var settingsFile
+ $settingsFile.withLock { $0.global.terminalHibernationEnabled = false }
+ let harness = makeHibernationHarness()
+ await harness.store.send(.selectedWorktreeChanged(harness.worktreeID)) {
+ $0.selectedWorktreeID = harness.worktreeID
+ $0.recentWorktreeIDs = [harness.worktreeID]
+ }
+ await harness.store.send(.detachLayout(worktreeID: harness.worktreeID)) {
+ $0.layouts = []
+ $0.recentWorktreeIDs = []
+ }
+ }
+
@Test func layoutsHydrationServesConsistentRecordsOnly() async {
let paneID = PaneID()
let good = Self.layout(paneID: paneID, tabID: TabID(), contentID: ContentID())
diff --git a/supacodeTests/WorktreeContentHostTests.swift b/supacodeTests/WorktreeContentHostTests.swift
index 365a5664c..3ee13936b 100644
--- a/supacodeTests/WorktreeContentHostTests.swift
+++ b/supacodeTests/WorktreeContentHostTests.swift
@@ -131,6 +131,48 @@ struct WorktreeContentHostTests {
host.trackBlockingScript(kind: .archive, tabID: tabID, launchDirectory: nil)
#expect(content.terminalChrome.isReadOnly == false)
}
+
+ @Test func aReportedTitleLandsOnTheChromeAndRearmsPersistenceOnce() {
+ let surfaceID = UUID()
+ let contentID = ContentID(rawValue: surfaceID)
+ let runtime = ContentRuntime()
+ let content = ChromeTabContent(id: contentID)
+ #expect(runtime.provision(content, at: .fallback))
+ let host = makeHost(layout: singleTabLayout(contentID: surfaceID), runtime: runtime)
+ var sentLayoutActions = 0
+ var persistenceRearms = 0
+ host.sendLayoutAction = { _ in sentLayoutActions += 1 }
+ host.onReportedTitleChanged = { persistenceRearms += 1 }
+
+ host.updateReportedTitle(for: contentID, title: "claude")
+ // An unchanged report is dropped before it can touch the chrome.
+ host.updateReportedTitle(for: contentID, title: "claude")
+
+ #expect(content.terminalChrome.reportedTitle == "claude")
+ #expect(persistenceRearms == 1)
+ // The whole point: a title storm never reaches the store.
+ #expect(sentLayoutActions == 0)
+ }
+
+ @Test(.dependencies) func anEmptyReportedTitleIsIgnoredSoTheLabelHoldsItsLastValue() {
+ let surfaceID = UUID()
+ let contentID = ContentID(rawValue: surfaceID)
+ let runtime = ContentRuntime()
+ let content = ChromeTabContent(id: contentID)
+ #expect(runtime.provision(content, at: .fallback))
+ let host = makeHost(layout: singleTabLayout(contentID: surfaceID), runtime: runtime)
+ var persistenceRearms = 0
+ host.onReportedTitleChanged = { persistenceRearms += 1 }
+
+ host.updateReportedTitle(for: contentID, title: "~/project")
+ // A shell that clears the title mid-command must not flash the label: the
+ // empty and whitespace reports are dropped, keeping the last real title.
+ host.updateReportedTitle(for: contentID, title: "")
+ host.updateReportedTitle(for: contentID, title: " ")
+
+ #expect(content.terminalChrome.reportedTitle == "~/project")
+ #expect(persistenceRearms == 1)
+ }
}
/// Pins the render-host claim invariants the steal-proof mount depends on.